Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,135 @@ event loop's clock, so every timed mute resolved to 1970 and did nothing.
`chat secret list|start|send` are registered and refuse with `NOT_SUPPORTED`
(exit 13) — tlgr has no end-to-end layer; `chat secret discard` works.

`chat create` and `chat members` are unchanged from v1 and migrate with the
groups-and-channels group.
### Groups and channels

Members, admins, invites, topics and the Manage screen. Three shapes matter
before you script any of it.

* **A member is a participant, not a user.** Every row keeps its wrapper —
`status` (creator/admin/member/self/restricted/banned/left), `rank`, `date`,
`inviter_id`, `promoted_by`, `kicked_by`, `admin_rights`, `banned_rights` —
so "is this person banned or merely restricted" is answerable.
* **Rights are always allow-polarity.** Telegram stores banned rights inverted
(`send_messages=true` means *cannot* send); tlgr normalises once, so `true`
means allowed everywhere, including in `banned_rights`.
* **Both masks are replaced, never patched, server-side.** Every writing
command reads the current mask first and sends a complete one, which is why
`chat member restrict --deny send-media` does not hand back the four other
restrictions somebody set last week.

```
tlgr chat member list <chat> [--filter recent|admins|bots|contacts|kicked|banned|restricted|mentions]
[--search Q] [--via-link LINK] [--topic ID] [--limit N] [--all]
→ page of {"id", "user_id", "username", "name", "status", "rank", "date",
"inviter_id", "promoted_by", "kicked_by", "admin_rights", "banned_rights"}
# `chat members` is v1's spelling and still works. --filter kicked is people
# who were removed; --filter restricted is people still in the chat with a
# mask on them. A chat with participants_hidden answers exit 6, not an empty
# page — "nobody is in this group" would be a lie.

tlgr chat member get <chat> <user> # + effective_permissions: defaults patched with their mask
tlgr chat member add <chat> <user>... # missing[] carries {user_id, reason} verbatim
tlgr chat member remove <chat> <user>... # a kick: they may rejoin
tlgr chat member ban <chat> <user>... [--until 7d] [--purge] [--messages ID] [--report] --yes
tlgr chat member unban <chat> <user>...
tlgr chat member restrict <chat> <user> [--deny R,R] [--allow R,R] [--none|--all|--clear]
[--replace] [--until 7d] [--purge]
tlgr chat member edit <chat> <user> [--rank TITLE] [--free-messages on|off] [--refund]
tlgr chat member delete-history <chat> <user> --yes
tlgr chat member report <chat> <user> [--messages ID] --yes

tlgr chat admin list <chat> [--no-rights]
tlgr chat admin promote <chat> <user> [--rights R,R|--grant R,R|--revoke R,R|--all|--none]
[--except R,R] [--rank TITLE] [--anonymous]
tlgr chat admin demote <chat> <user> --yes
tlgr chat permission list [--mask admin|member|all] [--chat CHAT] # the canonical names
tlgr chat permission get <chat> → {"allow": [...], "deny": [...], "rights": {...}}
tlgr chat permission set <chat> [--allow R,R] [--deny R,R] [--all|--none] [--replace]
tlgr chat transfer <chat> <user> --password-stdin --yes # 2FA, irreversible
tlgr chat admin-log list <chat> [--filter join,ban,kick,…] [--admin USER] [--search Q]
→ page of {"id", "date", "user_id", "action", "raw_type", "prev", "new"}
tlgr chat admin-log report <chat> <msg_id> # anti-spam false positive
```

`chat permission list` is the single source of truth for right names.
`manage-linked-peers` and `manage-welcome-messages` are layer-229 flags
Telethon 1.44 cannot express: they are listed with `"supported": false` and
asking for one exits 13 rather than granting less than you asked for.

Invites, join requests and topics:

```
tlgr chat invite create <chat> [--title T] [--expires 7d] [--limit N|--request-approval]
[--subscription-stars N] [--replace-primary]
tlgr chat invite list <chat> [--admin USER] [--revoked] [--by-admin]
tlgr chat invite get <chat> <link> | chat invite get <t.me/+hash> [--qr] [--png PATH]
tlgr chat invite edit <chat> <link> [--title T] [--expires …] [--limit N] [--request-approval on|off]
tlgr chat invite revoke <chat> <link> --yes # the permanent link is replaced, both are reported
tlgr chat invite delete <chat> [<link>|--revoked] --yes
tlgr chat invite open <t.me/+hash> # read a peek without joining
tlgr chat join <@name|t.me/+hash>
→ {"chat_id", "title", "joined", "pending_approval", "already"}
# All three outcomes exit 0: joined, already a member, and request-sent.

tlgr chat request list <chat> [--link L] [--search Q] [--approve USER] [--decline USER]
[--approve-all] [--decline-all]
tlgr chat request approve|deny <chat> [<user>...] [--all] [--link L] --yes

tlgr chat topic list <chat> [--search Q] [--closed] [--hidden] [--pinned]
tlgr chat topic get <chat> <topic>... # a deleted topic comes back as {"id", "deleted": true}
tlgr chat topic create <chat> <title> [--icon-emoji ID] [--icon-color RGB]
tlgr chat topic edit <chat> <topic> [--title T] [--icon-emoji ID|--no-icon] [--closed on|off]
tlgr chat topic close|reopen <chat> <topic>
tlgr chat topic hide|unhide <chat> # General (id 1) only
tlgr chat topic pin <chat> <topic>... [--reorder] | chat topic unpin <chat> [<topic>...|--all]
tlgr chat topic mute <chat> <topic> [8h] | chat topic unmute <chat> <topic>
tlgr chat topic delete <chat> <topic> --yes
tlgr chat topic read <chat> <topic> [--max-id N] [--mentions] [--reactions] [--list]
```

A topic id **is** the id of its creation service message, so it is exactly what
`message send --topic` and `message list --topic` take. General is id 1, always
exists, cannot be deleted, and is the only topic that may be hidden.

The Manage screen, and the numbers behind it:

```
tlgr chat create <title> [--type group|supergroup|channel|forum] [--about T] [--members USER]
[--photo PATH] [--username NAME] [--ttl 1d] [--geo lat,lon] [--tabs]
tlgr chat edit <chat> [--title T] [--about T] [--geo lat,lon|off] [--color ID] [--emoji-status ID]
[--main-tab posts|gifts|media|…] [--palettes]
tlgr chat convert <chat> supergroup|gigagroup --yes # one-way, both ids reported
tlgr chat setting get <chat> # every toggle, keyed as its flag
tlgr chat setting set <chat> [--slow-mode 30s] [--prehistory visible|hidden] [--forum on|off]
[--antispam on|off] [--hidden-members on|off] [--signatures on|off]
[--reactions all|none|👍,❤️] [--sticker-set NAME] [--ads on|off] …
→ {"chat_id", "changed": [...], "already": [...], "failed": {"key": "why"}}
tlgr chat username get <name> [--chat CHAT] | chat username set <chat> <name> [--order LIST]
tlgr chat username toggle <chat> <name> on|off | chat username unset <chat> [--all]
tlgr chat photo set <chat> <file>|--video PATH|--emoji-markup ID | chat photo delete <chat>
tlgr chat send-as list <chat> | chat send-as set <chat> <peer>
tlgr chat discussion list | chat discussion set <channel> <group> [--unhide-prehistory]
| chat discussion unset <channel>
tlgr chat stats get <chat> [--message ID|--story ID|--poll ID] [--load-graphs] [--out DIR]
tlgr chat stats list <chat> --message ID|--story ID # public reposts
tlgr chat revenue get <chat> [--ton] [--since USER] | chat revenue list <chat> [--in|--out]
tlgr boost get [<chat>] [--features] | boost list [<chat>] [--mine|--user U|--gifts]
tlgr boost add <chat> [--slots N]
```

`chat setting set` is a batch: a toggle already in the state you asked for is
reported in `already` and never sent, and a refusal lands in `failed` per key
instead of hiding the changes that did land. Statistics are routed to
`channelFull.stats_dc` automatically, and graphs are emitted as Telegram's own
chart spec — tlgr never redraws them. Revenue is read-only: withdrawing money
needs your 2FA password and belongs in an official client.

Seven commands are registered and refuse with `NOT_SUPPORTED` (exit 13):
`chat community create|list|set|ban` and `chat welcome list|set|delete` need
MTProto layer 229 and Telethon 1.44 speaks 227, so there is no request to
send. The command shapes are settled and will start working with the layer
uplift.

### Folders

Expand Down
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ on the busiest group, not on a toy one. `tlgr/cli/message.py` and
`tlgr/cli/draft.py` are deleted rather than shadowed.

The `chat` and `folder` groups follow: 47 more operations covering the dialog
list, the per-chat settings and the chat folders. `tlgr/cli/legacy/chat.py`
keeps exactly `chat create` and `chat members`, which are member-and-admin
operations and migrate with the groups-and-channels group.
list, the per-chat settings and the chat folders.

Then the groups-and-channels group: 86 more operations covering members,
admins, invites, join requests, topics, the Manage screen, the admin log,
statistics, revenue and boosts. `tlgr/cli/legacy/chat.py` is deleted along
with the `/chat/create` and `/chat/members` IPC routes and
`ClientWrapper.create_chat` / `list_participants`; both v1 paths keep working
through the registry. Seven commands — `chat community create|list|set|ban`
and `chat welcome list|set|delete` — are registered and refuse with
`NOT_SUPPORTED` (exit 13) because they need MTProto layer 229 and Telethon
1.44 speaks 227.

`auth`, `account` and `passport` follow: 51 more operations covering logging
in, the Devices list, 2-step verification, connected websites, passkeys and
Expand Down Expand Up @@ -101,6 +109,13 @@ Three more changed in the contact and user groups:
| 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 |

Two more in the groups-and-channels group:

| # | Change | v1 | v2 | Migration |
|---|---|---|---|---|
| 10 | `chat.member.list` (`chat members`) | `{"members":[{"id","first_name","last_name","username","is_bot"}]}` | `Page[Participant]`, each row keeping its `ChannelParticipant*` wrapper: `status`, `rank`, `date`, `inviter_id`, `promoted_by`, `kicked_by`, `admin_rights`, `banned_rights` | `--results-only` yields `{items, has_more, next_cursor, total}`; `id`, `username` and `is_bot` are unchanged, and `first_name`/`last_name` are joined into `name` (`--select name` reaches it). The dropped wrapper was why v1 could list members but not say whether one was banned or merely restricted |
| 11 | `chat.create` | `{"id","name","type"}` with `--type group\|channel` | `{"id","type","title","username","invite_link","added","missing"}` with `--type group\|supergroup\|channel\|forum` | `name` became `title` (`--select title`), and `--type group` still means the legacy basic group. `missing` names every seed member the server refused, instead of dropping them |

`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
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,32 @@ tlgr chat theme set <chat> --emoji 🌷
tlgr chat wallpaper set <chat> --slug pattern
tlgr chat badge get --limits # the unread badge, and the chat-list limits behind it
tlgr chat report <chat> --spam --yes
tlgr chat members <chat> # --admins, --search, --limit
tlgr chat create <name> # --type group|channel, --members
```

Members, admins, invites and topics:

```
tlgr chat member list <chat> --filter admins # `chat members` still works
tlgr chat member ban <chat> @spammer --purge --report --yes
tlgr chat member restrict <chat> @noisy --deny send-media --until 7d
tlgr chat admin promote <chat> @alice --rights ban-users,delete-messages
tlgr chat permission list --mask member # the canonical right names
tlgr chat permission set <chat> --deny send-stickers
tlgr chat invite create <chat> --limit 25 --expires 7d
tlgr chat request approve <chat> --all --yes
tlgr chat topic create <chat> Releases # the id `--topic` takes
tlgr chat setting set <chat> --slow-mode 30s --hidden-members on
tlgr chat create <name> --type supergroup # --members, --photo, --username
tlgr chat stats get <channel> --load-graphs
tlgr boost add <channel>
```

`chat list` returns a page of dialogs whose peer is nested under `chat`;
`chat catchup` and `chat list` never emit a read receipt, `chat open` does on
purpose, and `chat unread` restores only your own badge. Full reference:
purpose, and `chat unread` restores only your own badge. A member row keeps
its participant wrapper (status, rank, promoter, both rights masks) and every
mask is allow-polarity, so `chat permission get` round-trips straight back
into `chat permission set`. Full reference:
[`docs/reference/chat.md`](docs/reference/chat.md).

### Folders
Expand Down
114 changes: 114 additions & 0 deletions docs/design/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,3 +1014,117 @@ 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`.

## 2026-09-03 — one rights vocabulary, in `ops/_rights.py`, or four that drift

`chat admin promote --rights`, `chat member restrict --deny`, `chat permission
set --allow` and `bot default-rights set` all name the same two masks. Writing
the names four times would have produced four spellings within a release, so
the table lives in one module and every command reads it — which is also what
lets `chat permission list` be the answer to "what may I type here" instead of
a paragraph of documentation. The same module owns both conversions:
`ChatBannedRights` is stored inverted, and a single missing `not` turns "may
not send media" into "may send media", so the inversion happens once rather
than once per caller.

## 2026-09-03 — every mask is read before it is written

`channels.editBanned`, `channels.editAdmin` and
`messages.editChatDefaultBannedRights` replace the whole mask: a flag the
request omits is a flag the server clears. Sending only what the caller named
would therefore hand back every restriction somebody set last week, silently,
as a side effect of taking one new one away. `chat member restrict`,
`chat admin promote` and `chat permission set` all fetch the current mask
first and send a complete one, and the tests assert the *untouched* flags
survive rather than only that the named one changed.

## 2026-09-03 — a layer-229 right is refused, not dropped

`manage-linked-peers` and `manage-welcome-messages` exist in the API and have
no field in Telethon 1.44's `ChatAdminRights`. Accepting them and quietly
granting less than the caller asked for is the worst available outcome for a
permission change, so `chat permission list` marks them `supported: false` and
asking for one exits 13 with the reason. The same rule produced the seven
registered-and-refusing commands (`chat community *`, `chat welcome *`): a
command that refuses with an explanation tells an agent what is missing and
when it arrives; a command that is absent tells it nothing.

## 2026-09-03 — `chat setting set` is a batch that reports per key

The Manage screen is one screen in the GUI and about twenty MTProto methods
underneath, most of them gated on a different right or a boost level. Aborting
on the first refusal would let a boost-gated `--autotranslate` hide nine
changes that landed, and reporting only "ok" would hide the refusal. Flags are
therefore applied in a fixed order, a toggle already in the requested state is
reported in `already` and never sent, and each failure lands in `failed` under
its own key while the rest still run.

## 2026-09-03 — `chat request list` stays a read, and answers under its own guard

The work list marks the command mutating because `--approve`/`--decline` write.
Declaring the whole op mutating would make `--dry-run chat request list` print
a stub instead of the queue — the wrong trade for the command people run to
*see* who is waiting. The answer flags check `ctx.dry_run` themselves and warn
instead of firing, which is the rule `folder list --tags` already follows.

## 2026-09-03 — every `stats.*` call follows STATS_MIGRATE

Telegram answers a statistics request on the wrong data centre with
`STATS_MIGRATE_X`, and a client that does not follow it reports "no
statistics" for a channel that has plenty. Telethon's `get_stats` handles this
for two of the calls; tlgr needs six, so the migration is followed once in
`_stats()` through the same borrowed exported sender, and `chat stats list`,
`--load-graphs` and the story and poll variants all get it for free.

## 2026-09-03 — a graph is emitted verbatim, and revenue is read-only

Telegram's chart specification is a JSON document with columns, types,
colours, subcharts and stacking rules. Redrawing it would mean re-deciding
every one of those, in a CLI with no viewport, and a chart that is subtly
wrong is worse than no chart — so `chat stats get` reports the API's own
payload and lets the caller render. For the same class of reason
`payments.getStarsRevenueWithdrawalUrl` is not implemented: it moves money and
wants the 2FA password, so `chat revenue get` says a withdrawal is available
and points at an official client.

## 2026-09-03 — tlgr never reports a sponsored-message impression

`messages.viewSponsoredMessage` and `clickSponsoredMessage` tell the server an
advert was seen. A headless CLI has no viewport, so calling them would be
reporting something that did not happen — to the advertiser and to the channel
owner both. `chat sponsored list` therefore exposes the messages as data with
`viewed: false`, and the owner-side switch stays `chat setting set --ads off`.

## 2026-09-03 — the QR code needs an optional extra rather than an encoder

`chat invite get --qr` renders locally; the API contributes nothing beyond the
link. Carrying a pure-Python QR encoder for one P2 flag is a lot of surface to
own and to test, so the command tries `segno` (`pip install 'tlgr[qr]'`) and,
when it is absent, warns and still reports the link. The catalog id is claimed
as partial with that note, so the parity number does not overstate what a
default install does.

## 2026-09-03 — no bare `chat similar` alias

The work list lists `chat similar` as an alias of `chat similar list`. Click
cannot hold a command and a group under one name, so registering it would
replace the `chat similar` *group* and take `chat similar list` off the tree
entirely. The canonical three-word path is the only spelling; nothing else in
the group has this shape.

## 2026-09-03 — `chat permission list` runs in the daemon

The vocabulary is a static table and would happily be a `Surface.LOCAL`
operation, except that `--chat` marks which rights *you* may currently grant,
which needs your own participant row. Splitting the command in two for one
flag would be worse than one round trip, so the op is a normal daemon
operation with `rate_class="local"`: without `--chat` it answers from the
table and touches no network.

## 2026-09-03 — the reference stays one page per group

`docs/reference/` is generated one page per top-level noun, so the 86 new
operations land in `chat.md` and `boost.md` rather than in a hand-named
`chat-admin.md`. Adding a second grouping rule to the generator would mean the
page a command lives on is no longer derivable from its id, which is the
property that makes the docs impossible to get out of sync.
Loading
Loading