fix: refuse an ambiguous nickname instead of picking the first peer

/msg, /block, and geohash DMs took the first map entry for a shared
nickname (Cure53 BCH-01-003 / #589). Mentions already fail closed;
use the same unique-or-nil rule, with the people-list #xxxx suffix
to pick a specific peer.
This commit is contained in:
Taksh 2026-08-25 08:03:06 +05:30
parent eab894ce78
commit 2f72d1a07c
6 changed files with 168 additions and 41 deletions

View File

@ -40,10 +40,12 @@ class GeohashRepository(
@Synchronized
fun findPubkeyByNickname(targetNickname: String): String? {
return geoNicknames.entries.firstOrNull { (_, nickname) ->
val base = nickname.split("#").firstOrNull() ?: nickname
base == targetNickname
}?.key
// Same unique-or-nil rule as mesh: colliding nicks must use the
// people-list suffix (last four hex characters of the pubkey).
return com.bitchat.android.ui.uniquePeerIDForNickname(
targetNickname,
geoNicknames,
) { pubkey -> "#${pubkey.takeLast(4)}" }
}
@Synchronized

View File

@ -342,6 +342,54 @@ fun splitSuffix(name: String): Pair<String, String> {
return Pair(name, "")
}
/**
* Resolve a typed nickname to a mesh peer ID.
*
* An unsuffixed name is accepted only when it identifies exactly one peer.
* Colliding nicknames must be disambiguated with the `#xxxx` people-list suffix
* (first four hex characters of the peer ID), matching mention coloring.
*/
internal sealed class NicknameResolution {
data class Unique(val peerID: String) : NicknameResolution()
data object None : NicknameResolution()
data object Ambiguous : NicknameResolution()
}
internal fun resolvePeerIDForNickname(
query: String,
nicknames: Map<String, String>,
suffixOf: (String) -> String = { "#${it.take(4)}" },
): NicknameResolution {
val target = query.trim().removePrefix("@")
if (target.isEmpty()) return NicknameResolution.None
val (base, suffix) = splitSuffix(target)
val matches = linkedSetOf<String>()
for ((peerID, nickname) in nicknames) {
val nick = nickname.trim()
if (nick.equals(target, ignoreCase = true)) {
matches.add(peerID)
continue
}
if (suffix.isNotEmpty() && nick.equals(base, ignoreCase = true)) {
if (suffixOf(peerID).equals(suffix, ignoreCase = true)) {
matches.add(peerID)
}
}
}
return when (matches.size) {
0 -> NicknameResolution.None
1 -> NicknameResolution.Unique(matches.first())
else -> NicknameResolution.Ambiguous
}
}
internal fun uniquePeerIDForNickname(
query: String,
nicknames: Map<String, String>,
suffixOf: (String) -> String = { "#${it.take(4)}" },
): String? = (resolvePeerIDForNickname(query, nicknames, suffixOf) as? NicknameResolution.Unique)?.peerID
/**
* Build a case-insensitive mention-token lookup from canonical peer identities.
*

View File

@ -1075,7 +1075,7 @@ class ChatViewModel(
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key
return uniquePeerIDForNickname(nickname, mesh.getPeerNicknames())
}
fun toggleFavorite(peerID: String) {

View File

@ -86,42 +86,53 @@ class CommandProcessor(
private fun handleMessageCommand(parts: List<String>, meshService: MeshService, viewModel: ChatViewModel?) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
val success = privateChatManager.startPrivateChat(peerID, meshService)
if (success) {
if (parts.size > 2) {
val messageContent = parts.drop(2).joinToString(" ")
val recipientNickname = getPeerNickname(peerID, meshService)
sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService),
meshService,
viewModel
)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "started private chat with $targetName",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
when (val resolved = resolvePeerIDForNickname(targetName, meshService.getPeerNicknames())) {
is NicknameResolution.Unique -> {
val peerID = resolved.peerID
val success = privateChatManager.startPrivateChat(peerID, meshService)
if (success) {
if (parts.size > 2) {
val messageContent = parts.drop(2).joinToString(" ")
val recipientNickname = getPeerNickname(peerID, meshService)
sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService),
meshService,
viewModel
)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "started private chat with $targetName",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found. they may be offline or using a different nickname.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
NicknameResolution.Ambiguous -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "nickname '$targetName' is used by more than one peer; use the #xxxx suffix from the people list.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
NicknameResolution.None -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found. they may be offline or using a different nickname.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
} else {
val systemMessage = BitchatMessage(
@ -627,7 +638,7 @@ class CommandProcessor(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
return uniquePeerIDForNickname(nickname, meshService.getPeerNicknames())
}
private fun getPeerNickname(peerID: String, meshService: MeshService): String {

View File

@ -577,7 +577,7 @@ class PrivateChatManager(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
return uniquePeerIDForNickname(nickname, meshService.getPeerNicknames())
}
private fun getPeerNickname(peerID: String, meshService: MeshService): String {

View File

@ -310,6 +310,72 @@ class ChatUIUtilsTest {
assertEquals(null, resolveMentionPeerIdentity("@alice", identities))
}
@Test
fun `unsuffixed nickname is unique when only one peer uses it`() {
val resolved = resolvePeerIDForNickname(
"Alice",
mapOf("aabbccdd" to "alice")
)
assertEquals(NicknameResolution.Unique("aabbccdd"), resolved)
}
@Test
fun `unsuffixed nickname is refused when two peers share it`() {
val resolved = resolvePeerIDForNickname(
"alice",
mapOf(
"1111aaaa" to "alice",
"2222bbbb" to "alice",
)
)
assertEquals(NicknameResolution.Ambiguous, resolved)
assertEquals(null, uniquePeerIDForNickname("alice", mapOf(
"1111aaaa" to "alice",
"2222bbbb" to "alice",
)))
}
@Test
fun `hash suffix selects the colliding peer`() {
val nicknames = mapOf(
"1111aaaa" to "alice",
"2222bbbb" to "alice",
)
assertEquals(
NicknameResolution.Unique("1111aaaa"),
resolvePeerIDForNickname("alice#1111", nicknames)
)
assertEquals(
NicknameResolution.Unique("2222bbbb"),
resolvePeerIDForNickname("ALICE#2222", nicknames)
)
}
@Test
fun `unknown nickname resolves to none`() {
assertEquals(
NicknameResolution.None,
resolvePeerIDForNickname("bob", mapOf("1111aaaa" to "alice"))
)
}
@Test
fun `geohash last-four suffix selects the colliding pubkey`() {
val nicknames = mapOf(
"1111aaaa" to "alice",
"2222bbbb" to "alice",
)
val lastFour: (String) -> String = { "#${it.takeLast(4)}" }
assertEquals(
NicknameResolution.Unique("1111aaaa"),
resolvePeerIDForNickname("alice#aaaa", nicknames, lastFour)
)
assertEquals(
NicknameResolution.Ambiguous,
resolvePeerIDForNickname("alice", nicknames, lastFour)
)
}
@Test
fun `message without mentions has no background chips`() {
val body = formatTextMessageBody(