Skip to content
117 changes: 102 additions & 15 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,36 +528,123 @@ match it — that is what `folder remove --exclude` is for.
### Contacts

```
tlgr contact list [--limit N] [--cursor TOKEN]
→ {"contacts": [{"id": ..., "name": ..., "username": ..., "phone": ...}], "has_more": false}

tlgr contact add <phone> [name]
→ {"added": true, "user_id": 123}
tlgr contact list [--limit N] [--cursor TOKEN] [--with-status] [--with-stories]
[--sort name|first-name|last-name|last-seen|added] [--mutual-only]
[--close-friends-only] [--ids-only] [--export vcard|csv|json --out PATH]
→ Page[Contact]: {"items": [{"id", "name", "username", "phone", "mutual", ...}],
"has_more": false, "next_cursor": null, "total": 2}

tlgr contact add <user|+phone> [name] [--first-name T] [--last-name T] [--note T]
[--share-phone] [--from-message <chat>:<id>]
→ {"added": true, "user_id": 123, "imported": [123], "retry": [], "reason": null}
# By USER it is contacts.addContact; by +PHONE it is contacts.importContacts.
# An empty `imported` with an empty `retry` is AMBIGUOUS: the number may have
# no Telegram account, OR its owner may refuse lookups by phone. `reason` says
# so. Do not report it as "no such user". `retry` entries are not failures --
# the server is asking for them again later.

tlgr contact rename <user> [--first-name TEXT] [--last-name TEXT]
→ {"saved": true, "user_id": 123, "first_name": "...", "last_name": "..."}
# Works on non-contacts too (saves them as a contact). Omitted parts keep the
# current profile name. Useful for tagging users with state markers.

tlgr contact remove <user>
→ {"removed": true}

tlgr contact search <query> [--limit N] [--cursor TOKEN]
→ {"contacts": [...], "has_more": false}
# current profile name. Useful for tagging users with state markers. An empty
# first name is sent as "." because the server rejects an empty one.

tlgr contact remove <user>... [--phone NUMBER]
→ {"removed": true, "user_ids": [123], "phones": []}
# Deleting by phone reaches numbers with no Telegram account and is
# irreversible server-side.

tlgr contact search <query> [--mine-only] [--global-only] [--recent] [--type KIND]
→ Page[FoundPeer]: each row carries `source`: mine | global | recent | sponsored.
# Adverts are OFF unless --with-sponsored. `--recent` is tlgr-local state.

tlgr contact note set <user> [text] [--clear] # private; read back by `user get --full`
tlgr contact status list [--online-only] # last-seen for every contact, one call
tlgr contact birthday list [--window DAYS]
tlgr contact close-friends list|set <user>... # --add/--remove are read-modify-write
tlgr contact blocked list [--stories] # the two blocklists are independent
tlgr contact blocked set <peer>... # REPLACES the list; the reply is the diff
tlgr contact top list|set [--category NAME] # frequent contacts
tlgr contact import <file.vcf|file.csv> [--batch-size N]
tlgr contact sync <file> [--apply] [--delete-missing] # prints the diff unless --apply
tlgr contact saved list [--invite-text] # every number ever uploaded
tlgr contact share <user> --to <chat>
tlgr contact share-phone <user> # irreversible disclosure
```

`contact status list` reports `by_me` on the coarse buckets (`recently`,
`last_week`, `last_month`). It means **our own** last-seen privacy caused the
coarseness — never report it as the peer hiding from us.

### Users

```
tlgr user get <user>
→ {"id": ..., "first_name": ..., "username": ..., "bio": ..., "is_bot": false, ...}
tlgr user get <user> [--full] [--translate-bio LANG]
[--from-chat CHAT --from-message ID]
→ {"id": ..., "first_name": ..., "username": ..., "bio": ..., "is_bot": false,
"status": "online", "stories_hidden": false, ...}
# Never prints an access hash: `access_hash_cached` says whether one is held.
# A bare numeric id resolves only from this account's peer cache -- there is no
# MTProto call that mints an access hash for one. For a `min` user (seen only
# inside a channel message) pass --from-chat/--from-message.
# No photo plus an empty status is a SIGNAL, not a verdict: this never claims
# "they blocked you". Use the global --select to pull out one field.

tlgr user block <user> [--stories] [--report-spam] [--delete-history]
→ {"peer_id": ..., "blocked": true, "stories_only": false, "deleted": false}
tlgr user unblock <user> [--stories]
→ {"peer_id": ..., "blocked": false, "already": false}

tlgr user can-message <user>...
→ Page[ContactRequirement]: {"user_id", "result": free|premium|paid, "stars_amount"}
# Pairs with dialog-status for cold-outreach gating: this answers "am I
# allowed to", dialog-status answers "have I already".

tlgr user chat list <user> [--leave-all] # groups and channels you share
tlgr user link <user> [--profile] [--text T] # `me --token` mints an expiring link
tlgr user photo list|set <user>
tlgr user music list <user>
tlgr user personal-channel get <user>
tlgr user birthday set <user> <date> # sends a visible message

tlgr user dialog-status <user> [--max-dialogs N]
→ {"ref": ..., "id": ..., "username": ..., "resolved": true, "has_dialog": true,
"message_count": 12, "source": "peer_dialogs", "reason": null}

tlgr user hide-stories <user> [--unhide]
tlgr user hide-stories <user>... [--unhide] [--all on|off]
→ {"user_id": ..., "username": ..., "hidden": true, "already": false}
# More than one peer fills `peers`; a single peer answers with exactly the
# four keys above.
```

### Resolving a reference

```
tlgr resolve peer <ref>... [--from-chat CHAT --from-message ID] [--ids botapi]
→ Page[ResolvedRef]: {"ref", "id" (raw), "marked_id", "type", "title",
"source", "resolved", "access_hash_cached"}
# `source` says HOW it was answered. An uncached bare numeric id FAILS
# (exit 5 or 13) rather than being guessed at -- there is no MTProto call that
# turns an id into an access hash for a non-bot account.

tlgr resolve username <name> [--type user|bot|group|channel]
# USERNAME_INVALID exits 2 (a typo); USERNAME_NOT_OCCUPIED exits 5 (free).

tlgr resolve phone <+number> [--offline] [--countries]
→ {"phone", "e164", "country", "resolved", "peer", "reason"}
# PHONE_NOT_OCCUPIED exits 13, NEVER 5: no account and a privacy refusal are
# indistinguishable. --offline formats and validates without an RPC.

tlgr resolve link <url> [--no-network] [--open] [--draft CHAT]
→ {"kind", "raw_url", "username", "msg_id", ..., "delegated_to"}
# Classifies any t.me / tg:// link into one of ~30 kinds and NEVER acts:
# `delegated_to` names the command that would (chat join, bot start, gift
# redeem, proxy add...). `t.me/+X` is a PHONE when X parses as a number and an
# invite hash otherwise.

tlgr resolve cache get [--type KIND] [--stale 7d] [--refresh PEER] [--purge]
# The per-account peer database. Access hashes are never printed, only
# `access_hash_cached`; they are per login session and worthless elsewhere.
```

`hide-stories` is Telegram's own "Hide Stories" menu item: the peer leaves the
Expand Down
21 changes: 19 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,23 @@ The update transport follows: `events`, `watch`, `daemon`, `sync`, `net`,
operations, 114 event types, and the `updates_sync_network` domain fully
accounted for.

Then `contact`, `user` and `resolve`: 38 operations covering the address book,
one person's profile, both blocklists, the phonebook and the reference
resolver every other group already leans on. `tlgr/cli/legacy/contact.py` and
`tlgr/cli/legacy/user.py` are deleted, along with their eight IPC routes and
the eight `ClientWrapper` methods behind them. The two semantics AGENT.md
freezes are unchanged: `user dialog-status` is still three-valued and still
exits 13 for "could not establish", and `user hide-stories` still reports
`already` and sends nothing when there is nothing to do.

### Breaking

Every change below applies **only to commands generated from the operation
registry** — in this release that is the `message`, `draft`, `chat`,
`folder`, `auth`, `account`, `passport`, `media`, `sticker`, `gif`, `emoji`,
`events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`,
`webhook` and `export` groups, `tlgr completion`, `tlgr status`,
`tlgr schema` and the `agent` group. Commands still
`webhook`, `export`, `contact`, `user` and `resolve` groups,
`tlgr completion`, `tlgr status`, `tlgr schema` and the `agent` group. Commands still
hand-written under `tlgr/cli/legacy/` behave exactly as they did in v1 until
their own migration PR, at which point these rules apply to them too.

Expand Down Expand Up @@ -84,6 +93,14 @@ status envelopes:
| 11 | `config.list` | the raw TOML document | `Page[ConfigEntry]`, one row per key with `value`, `default` and `source`; secrets redacted | `--defaults` includes keys still at their default; `config get <key>` is the point lookup |
| 12 | `config.keys` | `{keys: {name: {section, key, description}}}` | `Page[ConfigKey]` with `type`, `default`, `scope`, `requires_restart` and `help` | key names gained a section prefix (`idle_timeout` → `daemon.idle_timeout`); both spellings are accepted by `config get`/`set`/`unset` |

Three more changed in the contact and user groups:

| # | Change | v1 | v2 | Migration |
|---|---|---|---|---|
| 10 | `contact.list`, `contact.search` | `{"contacts":[…],"has_more":…}` | `Page[Contact]` | `--results-only` yields `{items, has_more, next_cursor, total}`; every v1 row key (`id`, `name`, `username`, `phone`) is still there, and `phone` is now normalised to E.164 |
| 11 | `contact.add` | `{"added": true, "user_id": 123}` | the same two keys plus `imported`, `retry`, `popular_importers` and `reason` | additive. `reason` is filled when the import came back empty, because "no such account" and "the owner hides their number" are indistinguishable and v1 reported the first |
| 12 | `user.get` | `{"id","first_name","username","bio","is_bot","status","stories_hidden",…}` | the same keys, plus everything `users.getFullUser` carries | additive; `--select` reaches any of it. `--field` is gone: the global `--select bio --results-only` does the same thing on every command |

`tlgr agent whoami --json` reports `output_schema_version: 2`, so an agent can
branch on the two sets without probing for each change.

Expand Down
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,25 +185,67 @@ Full reference: [`docs/reference/folder.md`](docs/reference/folder.md).
### Contacts

```bash
tlgr contact list
tlgr contact add <phone> [name]
tlgr contact list # --with-status --sort last-seen --export vcard --out FILE
tlgr contact add <user|+phone> [name] # --first-name --last-name --note --share-phone
tlgr contact rename <user> # --first-name, --last-name (tags non-contacts too)
tlgr contact remove <user>
tlgr contact search <query>
```
tlgr contact remove <user>... # --phone reaches numbers with no account
tlgr contact search <query> # --mine-only --global-only --recent
tlgr contact note set <user> <text> # --clear
tlgr contact status list # online / last-seen for every contact, in one call
tlgr contact birthday list
tlgr contact close-friends list|set
tlgr contact blocked list|set # --stories for the story blocklist
tlgr contact top list|set # frequent contacts, by category
tlgr contact import <file.vcf|csv> # bulk phonebook import
tlgr contact sync <file> # diff a phonebook against the server (--apply)
tlgr contact saved list # every number ever uploaded, account or not
tlgr contact share <user> --to <chat>
tlgr contact share-phone <user> # irreversible
```

An empty `contact add` by phone is **ambiguous** — the number may have no
account, or its owner may refuse lookups by phone — and `reason` says so
rather than the reply claiming "no such user".

### Users

```bash
tlgr user get <user>
tlgr user get <user> # --full --translate-bio LANG --from-chat/--from-message
tlgr user dialog-status <user> # does THIS account have prior history with them?
tlgr user hide-stories <user> # archive their stories for this account (--unhide)
tlgr user hide-stories <user>... # archive their stories for this account (--unhide)
tlgr user block <user> # --stories --report-spam --delete-history
tlgr user unblock <user>
tlgr user can-message <user>... # free | premium | paid (and the Stars price)
tlgr user chat list <user> # groups you share (--leave-all)
tlgr user link <user> # --profile --text; `me --token` for a contact token
tlgr user photo list|set <user>
tlgr user music list <user>
tlgr user personal-channel get <user>
tlgr user birthday set <user> <date>
```

`dialog-status` distinguishes "yes", "definitively no", and "cannot tell"
(exit 13) instead of guessing. Never infer "no history" from an entity
resolution error — see AGENT.md for why.

`hide-stories` is idempotent: it reads the current flag first and reports
`already: true` without an RPC, so a bulk pass over hundreds of peers is
nearly free to repeat.

### Resolving references

```bash
tlgr resolve peer <ref>... # @username | id | +phone | t.me link | me
tlgr resolve username <name>
tlgr resolve phone <+number> # --offline formats and validates, no RPC
tlgr resolve link <url> # classify any t.me / tg:// link (--open)
tlgr resolve cache get # inspect the per-account peer database
```

`resolve link` never *acts*: it says what a link is and names the command
that would follow it in `delegated_to`. A phone lookup that comes back empty
exits 13, never 5 — no account and a privacy refusal are indistinguishable.

### Media, stickers, GIFs and emoji

```bash
Expand Down
102 changes: 102 additions & 0 deletions docs/design/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -912,3 +912,105 @@ the CLI in-process, and any embedding program) silently read the real
the `.production` marker turned the bug into a hard failure rather than a
quiet one. Both now call `paths.default_base()` at call time. `CONFIG_DIR`
survives for the legacy modules that will be deleted with their groups.

## 2026-09-03 — a result can be indeterminate *and* still be a result

`user dialog-status` has to answer three ways and AGENT.md freezes all three,
including `resolved=false, has_dialog=null` with exit 13. Raising
`IndeterminateError` would have satisfied the exit code and thrown away the
body — and the body is where `reason` and `scanned_dialogs` live, which is
what a caller needs to decide what to do next. (It would not even have
produced exit 13 over IPC: `IndeterminateError.http` is 200, so the transport
reads it as success.) The context therefore grew `mark_indeterminate(reason)`,
the daemon puts `meta.indeterminate` on the envelope, and the CLI exits 13
after rendering. `contact top list` on an account with the feature turned off
and `resolve phone` on an unoccupied number use the same mechanism, for the
same reason: an empty list and a "not found" would both be claims nobody
established.

## 2026-09-03 — `contact` and `user` models emit their falses

`Model` sets `omit_defaults=True`, so "absent" can mean "not applicable". For
this group the opposite is true: `already: false`, `added: false`,
`hidden: false`, `resolved: false` and `has_dialog: null` *are* the answer,
and v1 printed every one of them. `ContactModel` therefore sets
`omit_defaults=False` and every contact/user shape inherits it. `ResolvedLink`
is the exception and stays compact — a null for each of thirty link kinds is
noise — with `kind` made a required field so the one thing that must never be
missing cannot be.

## 2026-09-03 — `user get` is single-target, and has no `--field`

The work list had `user get <user>...` variadic. A spec has one response type,
so a variadic `user get` would have had to return `Page[UserProfile]` — and
AGENT.md documents `tlgr user get <user>` returning a bare object with `id`,
`first_name`, `username`, `bio`, `is_bot`. Keeping the documented shape won;
several users at once is `resolve peer` (which is variadic) or a loop.
`--field` went the same way: the global `--select bio --results-only` already
projects any field of any op, and a second, per-command spelling of one idea
is what STYLE §1 exists to prevent. `--refresh` went with it — tlgr keeps no
`userFull` cache to bypass; the 60 s one is the server's.

## 2026-09-03 — `user hide-stories` is variadic without changing its shape

v1 took one peer and returned `{user_id, username, hidden, already}`; the
whole point of the command is bulk passes over hundreds of peers, which a
loop of one-peer calls makes needlessly expensive to write. It now accepts
several, and a single peer still answers with exactly v1's four keys — extra
peers appear in `peers` — so nothing that reads the documented shape has to
change.

## 2026-09-03 — a two-segment alias cannot shadow a three-segment group

`contact saved`, `contact joined`, `user music`, `user personal-channel` and
`resolve cache` were all going to be short aliases for their `… list`/`… get`
operation. The Click tree builder places a command at a path, so each of them
would have *replaced* the group of the same name and taken its subcommand with
it. They were dropped rather than teaching the builder to be a command and a
group at once; the pluralised aliases that collide with nothing
(`contact statuses`, `contact birthdays`, `user photos`, `user blocked`) stay.

## 2026-09-03 — `contact list --export` writes a file, and only a file

The implementation runs in the daemon, so `--out -` would write to the
daemon's stdout, not the caller's. `--export` therefore requires `--out PATH`
and refuses `-` with a sentence saying why; `--json`/`--plain` already give
machine-readable output on the caller's stdout. The same rule makes
`contact import -` and `contact sync -` refusals rather than silent reads of
the wrong stdin. Exported phonebooks are written 0600: a contact list is
exactly the kind of file that should not become world-readable because a
shell redirect was convenient.

## 2026-09-03 — `resolve link` classifies, and never acts

Twenty-odd link kinds, one command, because the human pasting a link does not
know which kind it is — that is the question. Following one is always a
different, confirmed verb (`chat join`, `bot start`, `gift redeem`,
`proxy add`), and `delegated_to` names it. `--open` is the one concession and
it performs a *read* only. The single ambiguity worth spelling out is
`t.me/+X`: it is a phone number when X parses as one and an invite hash
otherwise, and guessing wrong turns a lookup into joining a group.

## 2026-09-03 — `user chat list --leave-all` honours `--dry-run` itself

Listing shared groups is a read and must stay dry-runnable, so the op is not
`mutating`; but `--leave-all` writes. Declaring the op mutating would make
every plain listing print a dry-run stub, and declaring it destructive would
prompt on every listing. It follows `folder list --tags` instead: the write
branch checks `ctx.dry_run` and says what it would do, and the help says
plainly that it leaves immediately.

## 2026-09-03 — `link.py` folded into `resolve.py`

PR-5's scope names a `link` module, but the work list contains no `link.*`
operation: the two link commands are `resolve link` (classify a t.me/tg:// URL)
and `user link` (build one). They live with the group whose noun they carry
rather than in a module that would hold one function and a docstring.

## 2026-09-03 — `resolve peer`, `resolve phone` and `resolve username` are verbs

STYLE §1's verb list has no entry for these, and the registry lints the last
path segment against it. COMMANDS.md's conventions already carve out
verb-first nouns (`resolve <kind>`, `search <scope>`), so `peer`, `phone` and
`username` were added to `VERBS` with a comment saying which rule they arrive
under — rather than bending the paths into `resolve peer get`.
Loading
Loading