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
108 changes: 101 additions & 7 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,13 @@ an envelope:
`--results-only` prints the inner value in both cases, which is v1's shape,
and `--select a.b,c` projects fields by dot path. `meta.already: true` marks an
idempotent no-op (the world already looked the way you asked for) — success,
not an error. Commands still under `tlgr/cli/legacy/` print v1's bare object.
not an error. Every command answers in this envelope: 2.0.0 removed the last
hand-written v1 command, so there is no second output shape to branch on.

`tlgr agent whoami --json` reports `output_schema_version: 2`; branch on that
rather than probing for each changed shape.
rather than probing for each changed shape. Coming from 1.x, the six shapes
that changed and what to do about each are in
[docs/UPGRADING.md](docs/UPGRADING.md).

## Pagination

Expand Down Expand Up @@ -831,16 +834,107 @@ makes the answer authoritative. It reports on the dialog list: a conversation
the account itself deleted is gone server-side too and correctly reads as no
dialog.

### Profile
### Profile, privacy, notifications and settings

`profile get` fetches the full user, so `bio` is the bio — v1 answered `""`
for every account because it never made the second call. `--no-full` skips
that call and omits `bio` entirely, which is how "not fetched" is told apart
from "empty".

```
tlgr profile get [--no-full]
→ {"id": ..., "first_name": ..., "last_name": ..., "username": ..., "phone": ...,
"bio": ..., "birthday": "10-12", "premium": true, "usernames": [...]}

tlgr profile update [--first-name T] [--last-name T] [--bio T] [--birthday D]
[--channel CHAT] [--photo PATH]
→ {"changed": ["bio"], "bio": "..."} # only the fields it wrote

tlgr profile photo set FILE|--photo-id ID|--emoji ID # --fallback for the public one
tlgr profile username set NAME [--check] [--on|--off] [--order LIST]
tlgr profile status set EMOJI [--until WHEN] | --clear
tlgr profile presence set online|offline # tlgr reports neither unless asked
tlgr profile link [--qr] [--collectible]
```

Privacy keys are read-modify-written: `account.setPrivacy` replaces the whole
ordered vector, so `--add-allow`/`--add-disallow`/`--remove` edit a list in
place and `--allow`/`--disallow` replace one.

```
tlgr privacy get [KEY] # omit KEY for every key
tlgr privacy set KEY [RULE] [--add-disallow @user] [--remove @user]
→ {"key": "last-seen", "base": "contacts", "deny_users": [777123]}

tlgr privacy global get|set [--hide-read-marks on|off] [--paid-messages-price N]
tlgr privacy blocked list|set PEER [--unblock] [--stories]
```

`notify` takes a *target* — a scope, a chat, a topic, `reactions` or
`contact-joined` — and picks between three server APIs. `mute_until` is an
absolute UNIX timestamp computed from the wall clock; `forever` is Telegram's
own sentinel.

```
tlgr notify get private|groups|channels|stories|reactions|contact-joined|<chat>
tlgr notify set <target> [--mute 2h|forever] [--unmute] [--sound ringtone:ID]
tlgr notify exception list|clear [CHAT...]
tlgr notify ringtone list|set FILE
```

`settings` addresses fifteen cloud-synced keys by name. Every row carries
`accepts`, the exact token vocabulary its setter takes, so a read pipes into
a write.

```
tlgr settings get [KEY]
→ {"key": "auto-delete", "value": "off", "accepts": "1d|1w|1m|<duration>|off"}

tlgr settings set KEY VALUE... # auto-delete, sensitive-content,
# top-peers, quick-reaction, browser,
# language, auto-download.<preset>.<field>…
tlgr settings unset top-peers|browser-exception|autosave|saved-tag [VALUE]
```
tlgr profile get
→ {"id": ..., "first_name": ..., "last_name": ..., "username": ..., "phone": ...}

tlgr profile update [--first-name TEXT] [--last-name TEXT] [--bio TEXT] [--photo PATH]
→ {"updated": true}
### Business, Premium, Stars and gifts

`business bot set` grants no right you did not name — there is deliberately no
`--all`, because a connected bot can read your messages, reply as you, rewrite
your profile and move your Stars.

```
tlgr business get [--timezones]
tlgr business set --tz ID --open 'mon-fri 09:00-18:00' [--address T] [--intro-title T]
tlgr business reply list|add|edit|delete|send
tlgr business bot list|set BOT --reply-to --read …|toggle CHAT
```

`premium feature list --limits` is the part a script needs: caption length,
upload size, folder counts and pinned chats all change with the subscription,
and guessing them writes a message the server refuses.

```
tlgr premium status | feature list [--limits] | boost list | gift list
tlgr stars balance get [--ton] | transaction list [--in|--out] | subscription list
tlgr stars rating get | revenue get CHAT | url get CHAT --amount N
```

A gift is addressed by a `ref`: `msg:<id>`, `<peer>:<saved_id>`, or a
collectible slug. Every time gate the server publishes is reported, because
"not yet, and here is when" is a different answer from "never".

```
tlgr gift catalog | list [PEER] | get REF | unique get SLUG
tlgr gift set REF --save|--pin|--wear
tlgr gift convert REF | upgrade REF | transfer REF PEER | craft REF...
tlgr giveaway get CHAT MSG_ID | join CHAT | list [CHAT] | code check|apply SLUG
```

**tlgr never spends money.** `premium gift send`, `business stars transfer`,
`stars subscription refulfill` and the paid halves of `gift upgrade`,
`gift transfer` and `gift offer approve` fetch the price, report it, and stop
with `ok: false` and a reason. There is no flag that changes that.

### Media, stickers, GIFs and custom emoji

Both v1 spellings still work (`tlgr dl`, `tlgr up`), and both answer with more
Expand Down
107 changes: 95 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ All notable changes to tlgr are recorded here. The format follows
semantic versioning at the CLI surface, which means the JSON shapes and exit
codes documented in `AGENT.md` are the public API.

## [Unreleased] — 2.0.0-dev
## [2.0.0] — 2026-09-04

**Every command tlgr has is generated from the operation registry.** There is
no hand-written command left, no v1 route left and no `ClientWrapper` left;
the daemon serves `/v1/*` and nothing else. Feature parity against the
official clients is 1788 of 1797 catalogued behaviours (99.5 %), and **all 178
P0 behaviours** — the ones ARCHITECTURE §1.3 required before this release.
The nine that remain are individually waived and each names the MTProto method
this build has no request class for.

The foundation of v2: operations are defined once, as an `OperationSpec`, and
the command, its JSON Schema, its docs and its contract tests are generated
Expand Down Expand Up @@ -105,17 +113,43 @@ One bug fix rides along: `message get --json` now actually prints
made its two keyboard-rendering P0 ids true only on paper — a caller could see
no button, so a caller could press none.

Last, the settings surface: `profile`, `privacy`, `notify`, `settings`,
`business`, `premium`, `stars`, `gift` and `giveaway` — 90 operations covering
Settings ▸ Edit Profile, Privacy and Security, Notifications and Sounds,
Telegram Business, Premium and boosts, Stars and the gift and giveaway
screens, where v1 had two commands (`profile get` and `profile update`). Both
still work.

Three of those two commands' behaviours were wrong and are now right.
`profile get` fetches `users.getFullUser`, so `bio` is the bio rather than the
`""` v1 reported for every account. `profile photo set` uploads the file and
sends raw `photos.uploadProfilePhoto`; v1 called
`client.upload_profile_photo()`, which Telethon 1.44 does not have, so the
command could never have worked. And a mute is computed from the wall clock:
v1 used the asyncio event loop's clock, whose origin is arbitrary, so "mute
for an hour" produced a timestamp in 1970 and muted nothing.

Two of Telegram's APIs in this group replace their whole payload —
`account.setPrivacy` replaces the ordered rule vector, and
`account.setGlobalPrivacySettings` replaces the constructor — so both commands
read first and write back complete. `privacy set --add-allow` and `--remove`
exist precisely so a script never has to re-state a list it did not mean to
touch.

The no-spend policy PR-10 set for the `payment` group holds across this one:
`premium gift send`, `business stars transfer`, `stars subscription refulfill`
and the paid halves of `gift upgrade`, `gift transfer` and `gift offer
approve` report the price and stop. A test walks the registry's source and
fails if anything in the group names `sendStarsForm`, `sendPaymentForm`,
`validateRequestedInfo` or `fulfillStarsSubscription`.

### 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`,
`story`, `events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`,
`webhook`, `export`, `contact`, `user`, `resolve`, `bot`, `inline`,
`webapp` and `payment` 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.
Every change below applies to **every** command: they are all generated from
the operation registry now. Earlier releases in this cycle carried a list of
which groups were migrated and a note that the hand-written ones behaved as
they did in v1 until their own PR; there are no hand-written ones left, so the
list is gone and the rules are universal.

No documented command path disappears. Every migrated operation declares its
v1 paths, so `tlgr send`, `tlgr msg list`, `tlgr message react` and the rest
Expand Down Expand Up @@ -168,6 +202,13 @@ Two more in the groups-and-channels group:
| 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 |

Two more in the settings group, which are the only two commands v1 had there:

| # | Change | v1 | v2 | Migration |
|---|---|---|---|---|
| 12 | `profile.get` | `{"id","first_name","last_name","username","phone"}` with `bio` always `""` | the same five keys, plus `bio`, `birthday`, `usernames`, `premium`, `personal_channel_id`, `emoji_status`, `color`, `stargifts_count`, `stars_rating` and the rest of `userFull` | additive, except that `bio` now carries the real value. `--no-full` skips the second round trip and omits `bio` entirely, which is how "not fetched" is told apart from "empty" |
| 13 | `profile.update` | echoed the whole updated profile | `{"changed": [...]}` plus only the fields it wrote | the command spans four RPCs and a report listing what you did not ask for is one nobody can act on. `profile get` reads the whole profile back |

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

Expand All @@ -194,6 +235,34 @@ Two more, outside the documented output shapes:

### Added

- **The settings surface: `profile`, `privacy`, `notify`, `settings`,
`business`, `premium`, `stars`, `gift` and `giveaway`.** 90 operations. Six
shapes are worth knowing before scripting against them:
- **A privacy key is read-modify-written, always.** `account.setPrivacy`
replaces the whole ordered vector, so `privacy set last-seen contacts`
would wipe every exception if tlgr sent only what changed. The exception
rules are written *before* the base rule, because the server applies the
vector in order.
- **`mute_until` is an absolute UNIX timestamp**, and `notify set --mute 2h`
turns a duration into one from the wall clock. `forever` is Telegram's own
sentinel (2³¹−1), not a tlgr convention.
- **`settings get`/`settings set` address fifteen cloud keys by name**, and
every row carries `accepts` — the exact token vocabulary its setter takes
— so a read can be piped back into a write.
- **A gift is addressed by a `ref`**: `msg:<id>` for one received in a
private chat, `<peer>:<saved_id>` for one a channel holds, or a
collectible slug (a `t.me/nft/` link is reduced to one). Every time gate
the server publishes — `can_transfer_at`, `can_resell_at`, `can_export_at`,
`can_craft_at` — is reported rather than collapsed into a boolean, because
"not yet, and here is when" is a different answer from "never".
- **`business bot set` grants no right you did not name.** There is no
`--all`: a connected bot can read your messages, reply as you, rewrite
your profile and move your Stars, and the reply enumerates exactly what
was granted.
- **The free/paid line runs through the gift commands.** Converting,
crafting, a free transfer, a prepaid upgrade, listing a collectible for
sale and declining an offer are performed; anything that needs a payment
form signed is priced and refused with `refused_reason`.
- **The `story` group.** 31 operations covering the whole story surface:
posting (with the audience vector, media areas, albums, reposts and a
soundtrack), the stories bar, a peer's active/profile/archive/album grids,
Expand Down Expand Up @@ -649,5 +718,19 @@ Two more, outside the documented output shapes:
- `tlgr/cli/message.py` and `tlgr/cli/draft.py`, and their `EXAMPLE_RESPONSES`
entries. The generated group replaces them outright — §12.4 forbids a group
being defined in both places, and a start-up assertion enforces it.
- The hand-rolled HTTP client in `ipc_client.py`. The module stays as a shim
over the new transport until the last v1 command migrates.
- **The whole v1 surface.** `tlgr/cli/legacy/` (the hand-written command
package), `tlgr/daemon/ipc.py` (the v1 route table), `tlgr/ipc_client.py`
and `transport.legacy_request` (the shim those routes were reached
through), and `tlgr/core/client.py` (`ClientWrapper`). Every documented v1
command path is still invocable as a `legacy_paths` alias on the operation
that replaced it, and `tests/test_agentmd_compat.py` walks the list. What is
gone is the *second* way to reach Telegram: the daemon serves `/v1/*` only,
so the peer-uid check, the policy allowlist, the version handshake, the
flood budget and the §7.2 error classification apply to every command
without exception.
- **`Daemon.status()`**, v1's `/daemon/status` body. Nothing has served it
over HTTP since the update-transport groups landed; `daemon status` answers
from the per-account state machine, which is where the COR-37 fix now lives.
- **The transport-level `flood_wait_max` default.** It existed because the
hand-written commands did not thread the flag into their own request bodies
(COR-15); every command threads it now.
57 changes: 50 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pip install tlgr

> **For agents:** logging in is a sequence of ordinary commands — `tlgr auth send-code` then `tlgr auth verify-code` — so only *reading the code* needs a person. Secrets come from `--x-env`/`--x-stdin`/`--x-file`, never argv. See [AGENT.md](AGENT.md) for the full agent reference.

> **Coming from tlgr 1.x with a running daemon?** Stop it before you upgrade — two processes on one session file is how an authorization gets revoked. [docs/UPGRADING.md](docs/UPGRADING.md) is the ten-minute cutover, including the six output shapes an agent has to adapt to.

## Quickstart

```bash
Expand Down Expand Up @@ -379,13 +381,51 @@ Three things this group does *not* do, on purpose:
A phone number, a location, a chat or a poll each needs its own flag;
without one tlgr prints what it would send and exits 2.

### Profile
### Profile, privacy and notifications

```bash
tlgr profile get # --no-full skips the userFull round trip
tlgr profile update # --first-name, --last-name, --bio, --birthday, --channel
tlgr profile photo set avatar.jpg # --video, --emoji ID, --photo-id ID, --fallback
tlgr profile username set ada # --check, --on/--off, --order a,b
tlgr profile status set 5301 # --until +7d, --clear
tlgr profile color set 5 # --profile, collectible:<slug>
tlgr profile presence set online # tlgr reports neither unless asked
tlgr profile link --qr # --collectible for the Fragment record

tlgr privacy get [key] # omit for every key
tlgr privacy set last-seen contacts --add-disallow @nosy
tlgr privacy global set --hide-read-marks on
tlgr privacy blocked list|set @spammer # --unblock, --stories

tlgr notify get private # or groups|channels|stories|reactions|<chat>
tlgr notify set private --mute 2h # --unmute, --sound ringtone:ID, --preview off
tlgr notify exception list|clear
tlgr notify ringtone list|set chime.ogg
```

### Settings, business, Premium, Stars and gifts

```bash
tlgr profile get
tlgr profile update # --first-name, --last-name, --bio, --photo
tlgr settings get # every cloud key, each with what its setter accepts
tlgr settings set auto-delete 1w # sensitive-content, top-peers, browser, language…
tlgr settings theme list|install Nord
tlgr settings language list

tlgr business get # hours, location, intro, greeting, away, links, bots
tlgr business set --tz Europe/London --open 'mon-fri 09:00-18:00'
tlgr business reply add hello --text "Hi! I will reply shortly."
tlgr business bot set @mybot --reply-to --read --new-chats # no --all, by design

tlgr premium status | premium feature list --limits
tlgr stars balance get | stars transaction list --out
tlgr gift list | gift get msg:120 | gift set msg:120 --pin
tlgr giveaway get @channel 42 | giveaway code apply <slug>
```

tlgr never spends money: the commands that would need a payment form signed
report the price and stop.

### Accounts

```bash
Expand Down Expand Up @@ -707,14 +747,17 @@ tlgr agent parity # coverage of the pinned Telegram feature
tlgr agent parity --json --uncovered # every gap, by priority and domain
```

The answer to "can tlgr do X yet" without guessing. Every uncovered id is
either waived to a named later PR or reported as a gap; nothing in the report
is hand-maintained. The same report is generated into
The answer to "can tlgr do X yet" without guessing. 2.0.0 covers 1788 of
1797 catalogued behaviours and all 178 P0 ones; the nine that remain are
individually waived and each names the MTProto method this build has no
request class for. Nothing in the report is hand-maintained — it is computed
from the registry — and the same report is generated into
[`docs/reference/PARITY.md`](docs/reference/PARITY.md).

### JSON envelope

Generated commands wrap their answer:
Every command wraps its answer — there is no hand-written command left to
answer any other way:

```json
{"ok": true, "op": "message.send", "result": {...}, "meta": {"request_id": "...", "elapsed_ms": 42}}
Expand Down
Loading
Loading