feat(contacts): remember contacts by the names you use; fix aliases being unusable for non-ASCII names - #178
Merged
Conversation
An alias is now free-text the user actually says: a contact can carry any number of them, which is what makes tags work — save both "андрей бекендер" and "бекендер" and either resolves. No separate tags field, no new tools: the map was always many-aliases-to-one-id. Matching tolerates Russian case endings and word order via commonprefix + difflib (no dependency). Three rules keep a guess off the wrong recipient: every query token must match some alias token (so "игорь смирнов" does NOT hit stored "чикичев игорь"), a reference matching two different ids never resolves, and username/phone/id/me shapes skip fuzzy entirely so an alias cannot hijack a real account. TELEGRAM_CONTACT_FUZZY=0 disables fuzzy. Learning loop with no new tools: unknown/ambiguous/stale references now return a structured instruction (nothing_sent, candidates, known_aliases) telling the agent to ask the user once, save the answer with set_contact_alias, and retry — after which that wording resolves silently. Fixes found while in there: - BLOCKER: @validate_id rejected every non-ASCII chat_id before apply_alias ran, so Cyrillic aliases were dead on send_message and ~76 other tools. - BLOCKER: log_and_format_error masked every message, so nothing could steer the agent; AliasNeedsUser now passes through. - save_aliases truncated in place — a partial write destroyed every alias. Now tmp+os.replace, 0600, and a corrupt file is quarantined, not clobbered. - load_aliases caught 2 of 5 possible error types; a malformed file took down every chat tool. Now never raises and skips only the bad rows. - An alias could shadow 'me', a real @username or a numeric id; set_contact_alias now refuses those and requires replace=True to repoint an existing alias. - Storage moved out of the install dir to XDG state (legacy file still read). - Alias write tools were annotated readonly=True while writing shared state. - alias_key folds case, NFC, ё/е and whitespace so equal-looking keys collide. - list_contact_aliases now shows one row per person with names, so a wrong memory is visible; delete stays exact-match only. Tests: 21 in test_aliases.py plus validation coverage; suite 218 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…testing Three defects, each caught by running the real flow against Telegram: - Adjective declensions never matched: the tail rule allowed 2 changed characters, but 'главному'/'главный' swaps 3. The rule is now a >=4-char stem (or a single-character swap on equal-length words, so 'лена'/'лене' works while 'олег'/'олеся' stays rejected) with endings up to 3 chars and a similarity backstop. Pinned by a table of 12 inflections that must match and 13 different-person pairs that must not. - An ambiguous reference was labelled unknown_contact, so the agent was told to ask who the person is instead of which of the listed candidates. - The stale-mapping branch was unreachable on every @validate_id tool: the decorator substitutes the stored id, so the resolver never saw the wording, and a dead peer raises an RPC error the resolvers did not catch. Ids from aliases now carry their wording (AliasID), and peer errors are translated into the same ask-to-repoint instruction. Also deduplicates the two resolvers, which were 60 lines of identical retry logic, into one helper — that is where the peer-error handling now lives. Tests: 49 in test_aliases.py; suite 246 passed; 26/26 live end-to-end checks against real Telegram (unknown -> ask -> save -> send, tag-only reference, ambiguity, shadow and repoint guards, stale, delete, username untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review (36 confirmed findings, 9 high) showed the fuzzy matcher could deliver a private message to the wrong person, verified end to end: saved "леня" (Leonid) + a send to "Лена" (Elena) resolved to Leonid; "иван" + "Иванов" likewise, as did 23 common first-name/surname pairs. The ambiguity guard cannot help there - when the intended person is not saved there is only ONE match, and it is the wrong one. Fuzzy matching therefore no longer resolves anything by itself: - apply_alias() is exact-key only, and the handle gate runs BEFORE the lookup so a handle-shaped key left in an old file cannot hijack "me" or a real @username. - A near miss goes back to the agent as a confirm_contact instruction naming the candidate, so the user answers one yes/no; the confirmed wording is saved as its own alias and resolves silently ever after. The "ask once, never again" promise is unchanged, just per wording. - Query tokens must claim DISTINCT alias tokens: "андрей андреев" matched a stored "андрей" because both words landed on the same token, so the surname naming someone else was free. - set_contact_alias refuses a target it would have to guess at, and never re-emits an ask payload from the save path (that caused a second question which could re-target the alias mid-loop). - Marked chat/channel id variants are no longer tried for an alias-resolved id: those are exact, and guessing variants can reach an unrelated chat. Storage hardening from the same review: - Writes go through update_aliases(), an flock-protected read-modify-write, so two concurrent saves no longer lose one and a delete is not undone. - A degraded read refuses to write (AliasStoreUnreadable) instead of replacing every remembered contact with a single entry. - Atomic write uses mkstemp instead of a fixed ".tmp" name shared between processes and open to symlink replacement. - Valid-JSON-but-wrong-shape files are quarantined like unparseable ones, and a non-string name no longer raises out of the loader. - Ask payloads are no longer logged at ERROR with the user's nickname. Tests: 58 in test_aliases.py (lookalikes, token distinctness, locking, unreadable-store refusal); suite 256 passed; 31/31 live checks against real Telegram. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chigwell
approved these changes
Aug 3, 2026
KiaroSama
pushed a commit
to KiaroSama/telegram-mcp
that referenced
this pull request
Aug 25, 2026
feat(contacts): remember contacts by the names you use; fix aliases being unusable for non-ASCII names
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Teaches the server the names you actually use for people, and — separately, and more importantly — fixes a bug that made the existing alias feature unusable for anyone whose contacts are not named in ASCII.
The blocker first
@validate_idcheckschat_idagainst^@?[a-zA-Z0-9_]{5,}$before the tool body reachesresolve_entity, soapply_aliasnever ran for a non-ASCII alias. Verified on the shipped code:send_message(chat_id="чикичев игорь")returnsInvalid chat_id, while an ASCII alias reaches the resolver. Every Cyrillic alias is dead onsend_messageand ~76 other decorated tools today.log_and_format_errormaskingstr(error)compounds it — nothing a resolver says can reach the agent.Tags without a tag field
The map has always been many-aliases-to-one-id, so a tag is just another alias: save both
андрей бекендерandбекендерfor one person and either resolves. Notagsfield, no new write path, no merge semantics, no name-vs-tag ranking. The value becomes{id, name, account}— the name snapshot is what makes a wrong memory visible inlist_contact_aliases, which now returns one row per person.Learning loop, no new tools
An unresolvable reference no longer dead-ends in
An error occurred (code: …). It returns a structured instruction —nothing_sent,candidates,known_aliases— telling the agent to ask the user once, save the answer withset_contact_alias, and retry. That wording then resolves silently forever. Four kinds:unknown,confirm(one lookalike),ambiguous(several),stale(saved id no longer resolves → repoint withreplace=True). Wired at two shared choke points, so all 95 resolver call sites inherit it.Only an exact wording sends
This is the load-bearing decision. An earlier revision resolved inflections silently (
Андрею бекендеру→ the savedандрей бекендер). Adversarial review killed it with evidence: savedленя+ a send toЛенаresolved to Leonid,иван+Ивановlikewise, and 23 common first-name/surname pairs collide.Лена/Леняdiffers exactly as much as a case ending does, and the ambiguity guard cannot catch it — when the person you meant is not saved, there is only one match and it is the wrong one.So a lookalike now only suggests: the tool sends nothing and hands the agent the candidate by name for a yes/no. Confirming saves that wording, so a new phrasing costs one question the first time and nothing after.
TELEGRAM_CONTACT_FUZZY=0drops suggestions too. Query tokens must also claim distinct alias tokens —андрей андреевused to match a storedандрейbecause both words landed on the same token.Other defects fixed while in there
save_aliasestruncated in place: a partial write destroyed every alias. Nowmkstemp+os.replaceunder anflock, so concurrent saves cannot lose one and a delete cannot be undone.load_aliasescaught 2 of 5 possible error types; a malformed file took down every chat tool. It never raises now and skips only bad rows. A degraded read refuses to write over the file it could not parse.me, a real@usernameor a numeric id; both the save and the resolve path now refuse.${XDG_STATE_HOME:-~/.local/state}/telegram-mcp/aliases.jsonat 0600 (the pattern requested in feat: favorite aliases, incoming event feed (callback mode), rich messages with Premium gating #176); the old file is still read as a fallback.readonly=Truewhile writing shared state.alias_keyfolds case, NFC, ё/е and whitespace, so equal-looking keys stop silently overwriting each other.AliasNeedsUseris not aValueError, because several tools wrap resolution inexcept ValueErrorand would mangle the instruction.Testing
58 unit tests for this feature (suite: 256 passed), including a pinned table of 12 inflections that must match and 13 different-person pairs that must not, plus locking, unreadable-store refusal, and shadow guards. Separately, 31 live end-to-end checks against a real account cover unknown → ask → save → send, tag-only wording, lookalike refusal, ambiguity, shadow and repoint guards, stale repointing, deletion, and a real
@usernamestaying untouched. Three defects were found by those live checks alone and are fixed here: adjective declensions never matched, ambiguity was labelledunknown, and the stale branch was unreachable on every decorated tool.🤖 Generated with Claude Code