Skip to content

feat(contacts): remember contacts by the names you use; fix aliases being unusable for non-ASCII names - #178

Merged
chigwell merged 3 commits into
chigwell:mainfrom
ex3lite:feat/contact-memory
Aug 3, 2026
Merged

feat(contacts): remember contacts by the names you use; fix aliases being unusable for non-ASCII names#178
chigwell merged 3 commits into
chigwell:mainfrom
ex3lite:feat/contact-memory

Conversation

@ex3lite

@ex3lite ex3lite commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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_id checks chat_id against ^@?[a-zA-Z0-9_]{5,}$ before the tool body reaches resolve_entity, so apply_alias never ran for a non-ASCII alias. Verified on the shipped code: send_message(chat_id="чикичев игорь") returns Invalid chat_id, while an ASCII alias reaches the resolver. Every Cyrillic alias is dead on send_message and ~76 other decorated tools today. log_and_format_error masking str(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. No tags field, 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 in list_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 with set_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 with replace=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=0 drops 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_aliases truncated in place: a partial write destroyed every alias. Now mkstemp + os.replace under an flock, so concurrent saves cannot lose one and a delete cannot be undone.
  • load_aliases caught 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.
  • An alias could shadow me, a real @username or a numeric id; both the save and the resolve path now refuse.
  • Storage moved out of the install directory to ${XDG_STATE_HOME:-~/.local/state}/telegram-mcp/aliases.json at 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.
  • Alias write tools were annotated readonly=True while writing shared state.
  • alias_key folds case, NFC, ё/е and whitespace, so equal-looking keys stop silently overwriting each other.
  • AliasNeedsUser is not a ValueError, because several tools wrap resolution in except ValueError and would mangle the instruction.
  • The two resolvers were 60 lines of identical retry logic; they are now one helper, which is where peer-error handling lives.

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 @username staying untouched. Three defects were found by those live checks alone and are fixed here: adjective declensions never matched, ambiguity was labelled unknown, and the stale branch was unreachable on every decorated tool.

🤖 Generated with Claude Code

ex3lite and others added 3 commits August 3, 2026 16:42
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
chigwell merged commit 70690bd into chigwell:main Aug 3, 2026
4 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants