Skip to content

settings: profile, privacy, notifications, business, premium, gifts, stars — and the v1 surface retired (2.0.0) - #15

Merged
erfnzdeh merged 15 commits into
mainfrom
feat/pr12-settings
Sep 4, 2026
Merged

settings: profile, privacy, notifications, business, premium, gifts, stars — and the v1 surface retired (2.0.0)#15
erfnzdeh merged 15 commits into
mainfrom
feat/pr12-settings

Conversation

@erfnzdeh

@erfnzdeh erfnzdeh commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

PR-12, the last of the v2 plan: the remaining settings groups on the operation registry — profile, privacy, notify, settings, business, premium, stars, gift, giveaway (~90 operations) — and the retirement of the v1 surface. After this PR no hand-written command, no v1 IPC route and no ClientWrapper remain; the daemon serves /v1/* only; every v1 command path is still invocable through legacy_paths (superset test).

  • Profile: names, bio, usernames (order/toggle), photos (upload/delete/set/list/fallback), emoji status, birthday, personal channel, colours, wallpaper. Privacy: one get/set pair over every privacy key with allow/disallow rules and exceptions, plus global privacy. Notifications: per-scope defaults, exceptions, ringtones, reaction/story notifications. Settings: a generic get/set over the cloud settings (sensitive content, default TTL, language/translation, themes, web-browser settings, suggestions).
  • Business (hours, location, greeting/away, quick replies, chat links, connected bots, intro), premium (status, features, gifting), stars (balance, transactions, subscriptions — no purchases), gifts (send/list/save/convert/transfer/upgrade/pin/wear/collections/resale/offers/auctions), giveaways.
  • Parity gate flipped: every remaining waiver names a concrete reason (layer-229 method, prohibited, not-applicable, NOT_SUPPORTED op); no "owned by PR-N" waivers remain.
  • Release: version 2.0.0, CHANGELOG finalised with the breaking-change table and migration lines, README/AGENT.md describe v2 only, docs/UPGRADING.md documents the v1 → v2 cutover for an installed pipx tlgr with a running v1 daemon, acceptance criteria re-run.

Numbers

15 commits, 13,292 tests (238 new), ruff + mypy clean, coverage 82% (gate 80%). Parity: profile_settings_privacy 178/178 → P0 178/178 (100%), 1,788 catalog ids covered, 9 remaining waivers each naming the MTProto method Telethon 1.44 lacks; the gate refuses domain waivers, unknown kinds and waivers on covered ids. Three v1 bugs fixed on the way: profile get returned an empty bio, profile update --photo called a Telethon method that does not exist, and a mute computed from the event-loop clock. Money policy inherited from PR-10: paid actions report the price and stop, enforced by a test walking the registry. (Numbers before the rebase onto main, which since gained only PR #14.)

…um, stars and gift shapes

Eight files for the last group, and four of them exist because the server
shape and the human shape genuinely differ.

A username is a row, not a string: a Fragment collectible can be active or
parked and only the first active one is the handle a link resolves to, so
flattening the vector is how a script deactivates the wrong name. A privacy
key keeps `raw_rules` alongside the four exception lists, because
`account.setPrivacy` replaces the whole ordered vector and a rule tlgr does
not recognise still has to survive the round trip. `mute_until` carries its
unix twin so the model itself says the timestamp is absolute — v1 computed
it from the asyncio loop clock and muted chats into 1970. And a gift is
addressed by a `ref` string everywhere, so a reference read from a listing
can be handed straight to convert, transfer or set without a lookup table.

`BotRights` enumerates every flag of `businessBotRights` rather than
carrying a mask: connecting a business bot hands another program the right
to read, reply, edit the profile and move Stars, and a right that is absent
from the model is a right nobody can audit.
The Edit Profile screen, whole: names and bio, usernames including the
Fragment collectibles, the avatar history, the accent palettes, the emoji
status, the presence switch, the personal channel, the saved music, the
wallpaper and the public link.

Three of these are corrections rather than features. `profile get` now
fetches `users.getFullUser`, so `bio` is the bio instead of the `""` v1
hard-coded for every account. `profile photo set` uploads the file and sends
raw `photos.uploadProfilePhoto`, because v1 called
`client.upload_profile_photo()`, which Telethon 1.44 does not have — the
command could never have worked. And `profile update` is one command over
four RPCs, because the GUI shows one screen and `updateProfile` /
`updateBirthday` / `updatePersonalChannel` is the server's decomposition,
not a vocabulary an agent should have to learn.

`profile presence set` exists because a daemon needs a presence *policy*:
always reporting online advertises a machine, and reading history while
reporting offline is the classic bot tell, so tlgr reports neither unless
asked. The last v1 command module goes with it — `tlgr/cli/legacy/profile.py`
is deleted and both its paths stay invocable through `legacy_paths`.
`account.setPrivacy` replaces the whole ordered rule vector and
`account.setGlobalPrivacySettings` replaces the whole constructor, so both
commands read first and write back complete. That is what `--add-allow`,
`--add-disallow` and `--remove` are for: a script that wants to add one
exception should never have to re-state a list it did not mean to touch, and
a global flag nobody passed is written back exactly as it was found.

`notify get`/`notify set` take a target — a scope, a chat, a topic,
`reactions` or `contact-joined` — and pick between three unrelated server
APIs, because the official clients show them as one Notifications screen.
`mute_until` 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. `contact-joined` is reported the way a
human reads it, even though the wire stores the opposite (`silent=true`
means off).

`privacy set stories` is refused with a sentence naming the two commands
that do own story visibility, rather than accepted and silently ignored:
there is no `inputPrivacyKeyStories`, and pretending otherwise is worse than
saying so.
`settings get`/`settings set` address fifteen cloud-synced keys by name.
A dozen thin toggle commands would be a dozen names to learn, a dozen
response shapes and a dozen places to get the same read-modify-write wrong;
here every row carries `accepts`, the exact token vocabulary its setter
takes, so a read can be piped straight back into a write.

Where another group already implements a setting — sensitive media,
auto-download presets, the quick reaction, paid-reaction privacy, saved tags,
top peers, folder tags — the dispatcher calls that operation instead of
sending the RPC a second time. One implementation, two entry points, and no
second place for the two to disagree.

`settings theme *` is deliberately metadata only: a theme is a rendering
instruction and tlgr renders nothing, but publishing a theme file, installing
one and listing what is installed are server-side facts the phone sharing the
account will act on.
Telegram Business, whole: opening hours, location and chat intro, the
greeting and away messages, the quick replies they send, the public chat
links, and the chatbot that may act on the account's behalf.

`business bot set` grants another program the right to read my messages,
reply as me, rewrite my name, bio and photo, manage my gifts and transfer my
Stars. So every right is opt-in by name — there is deliberately no `--all` —
the operation is destructive, and the reply enumerates exactly what was
granted, because an audit should not have to trust the flags somebody typed.

Opening hours are minutes-of-week arithmetic and the easy thing to get wrong.
`--open 'mon-fri 09:00-18:00'` is expanded, sorted and merged before it is
sent, because the server rejects overlapping intervals and writing two lines
for the same day is the normal way a human produces them; a range that ends
before it starts is a usage error rather than a silent wrap, since
"22:00-02:00" almost always means the next day and guessing is worse than
asking.

`business stars transfer` prices the transfer and refuses to make it. PR-10
settled that tlgr never signs a payment form; this group inherits the policy
rather than opening a second door onto the same money.
…held

The limit table is what a script actually needs out of Premium: caption
length, upload size, folder count, pinned chats and public usernames all
change with the subscription, and a script that guesses them writes a
message the server then refuses. It has no MTProto method of its own — it is
assembled from appConfig — so every row says where it came from.

The Stars group reads and nothing else. A Stars amount keeps its `nanos`
because a rounded ledger cannot be reconciled, and the transactions cursor is
the server's opaque `next_offset` string rather than an integer: passing a
number where the API wants a token silently restarts the walk, which is how
an export ends up with its first page repeated.

Giveaways get a noun of their own because almost all of the surface is free:
joining spends a boost slot I already own, redeeming a code activates a
subscription somebody else paid for, and launching a *prepaid* giveaway
spends nothing at all. Buying a new one is a purchase and is absent.

`premium gift send`, `stars subscription refulfill` and `stars url get` are
where the policy shows. PR-10 named `sendStarsForm`, `sendPaymentForm`,
`validateRequestedInfo` and `fulfillStarsSubscription` as deliberately absent
from tlgr's surface; PR-12 does not re-add any of them behind a flag. Each of
these three reports the price, or the URL, or whether the server would allow
it — and stops there.
…them

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 bare collectible slug
(a t.me/nft/ link is reduced to one). One spelling means a reference read out
of `gift list` goes straight into `gift set`, `gift convert`, `gift transfer`
or `gift upgrade` with no lookup table in between.

Every time gate the server publishes is surfaced instead of collapsed. "Can I
transfer this?" has three answers — yes, not yet and here is when, never — and
a client that reports only the first two sends its user to wait for a date
that will not come.

The dividing line is cost, not danger. Displaying, pinning, wearing,
converting back into Stars, a free transfer, a prepaid upgrade, listing a
collectible for sale, declining an offer and crafting are all performed;
anything that would need a payment form signed is priced and refused with the
reason attached. `gift craft` is the exception that proves it: free, and
still gated behind `--yes`, because it burns every input gift whatever the
outcome.

Three flags name a method Telethon 1.44 has no request class for
(`canSendStarGift`, `getStarGiftCraftCandidates`, `getStarGiftAttributes`) and
refuse with exit 13 saying which; the rest of each command still works.
238 tests over a fake Telegram that holds state rather than answering canned
replies, because this group's whole job is round trips: a privacy vector that
is rewritten and read back, a notification exception that a later listing
finds, a gift that leaves one profile and arrives on another, a code that can
only be redeemed once.

Four of them are the ones worth having. `mute_until` is asserted against the
wall clock, which is the bug v1 shipped. `--add-allow` is asserted to keep
what was already there, because `setPrivacy` replaces the whole vector. One
test walks the registry's *source* and fails if anything in this group names
`sendStarsForm`, `sendPaymentForm`, `validateRequestedInfo` or
`fulfillStarsSubscription` — so a future addition cannot quietly re-open the
door PR-10 closed. And every flag that needs a request class Telethon 1.44
lacks is asserted to exit 13 with the method named, not 1.

Two things had to change to make the answers honest. Fields that carry the
answer — `ok`, `state`, `kind`, `upgraded`, `transferred`, `version`,
`resolvable_by_strangers`, the privacy `base` — lost their defaults, because
`omit_defaults` was dropping exactly the value a caller most needs to see;
absent must mean "not applicable", never "the interesting case". And ten
server error names this surface can produce (`THEME_INVALID`,
`GIFT_SLUG_INVALID`, `CHATLINKS_TOO_MUCH`, `USERNAME_PURCHASE_AVAILABLE`, …)
joined the error table, so they exit 5 or 2 with a reason instead of 1.

`profile` was the last hand-written group, so the sandbox's two-exit-code
rule collapses to one: every refusal is PERMISSION_DENIED now.
…nd here is why"

The waiver file was a backlog. Until this PR a waiver was a promise with a PR
number on it — "waived until PR-9" — which is exactly right while there are
still PRs to come, and meaningless once there are not. Every one of those 177
promises has been kept, so the file now holds nine ids and each one names a
permanent reason: seven layer-229 methods Telethon 1.44 has no constructors
for, and two methods the layer has that the pinned library does not ship a
request class for.

`kind` is the new required field — `layer-gap`, `absent-method`, `prohibited`
or `not-applicable` — and the gate enforces the whole shape rather than
trusting the file: no domain may be waived, no unknown `kind` may appear, a
layer or method waiver must name its method, and an id that is *covered* may
not also be waived, which is the one way a number could lie about itself.

P0 is 178 of 178. That is ARCHITECTURE §1.3's condition for 2.0.0 final, and
it now has a test of its own rather than being a floor that could be raised
one id at a time forever. Total coverage is 1788 of 1797.
…any more

Five modules go, and with them the last places where a command could reach
Telegram without passing through the registry:

* `tlgr/cli/legacy/` — the v1 command package. `profile get` and `profile
  update` were the last two commands in it, and they are registry operations
  now. Every path v1 documented is still invocable, as `legacy_paths` aliases
  on the ops that replaced them; `tests/test_agentmd_compat.py` walks the
  whole list and proves it.
* `tlgr/daemon/ipc.py` — the v1 route table. `POST /v1/op`, `GET /v1/events`,
  `GET /v1/status` and `POST /v1/admin/{action}` are the entire HTTP surface
  now, which means 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 rather than by being registered alongside.
* `tlgr/ipc_client.py` and `transport.legacy_request` — the shim those routes
  were called through, and the transport-level `flood_wait_max` default that
  existed only because the hand-written commands did not thread it into their
  own request bodies (COR-15). Every command threads it now.
* `tlgr/core/client.py` — `ClientWrapper`. 460 lines that owned a Telethon
  client, logged accounts in and out, and serialised messages, held alive
  because the job engine needed *two* of its methods. Those two are now
  `jobs/client.py`'s `JobClient` protocol — the raw client and a resolver —
  and `AccountSession.job_client` supplies them, so a reconnect swaps the
  client underneath a running job instead of leaving it holding a dead one.

`Daemon.status()` goes too: it was v1's `/daemon/status` body, and nothing
has served it over HTTP since PR-4. The COR-37 claim it carried — that a
client object existing and the link being usable are different facts — is
made where it now lives, on `AccountSession.connected` and in the per-account
`state` that `daemon status` answers from.

Three test modules exercised only `ClientWrapper.get_messages`; that surface
is `message list` and `tests/test_ops_message.py` owns it. `media_details`
was the classifier those tests were really about, so its table moved onto
`media_summary` rather than being deleted with it. The layering lint loses
its one exemption, because there is nothing left to exempt.
The Unreleased section becomes 2.0.0, dated 2026-09-04, with the
breaking-change table intact — twenty-eight rows across the groups, and the
two new ones are the only two commands v1 had in the settings surface:
`profile get` now reports the real bio, and `profile update` reports only the
fields it wrote.

The Breaking section loses its preamble listing which groups were migrated,
because the answer is all of them. The Removed section gains the v1 surface
itself. `README.md` and `AGENT.md` describe v2 only: no "commands still under
`tlgr/cli/legacy/` print v1's bare object", no "generated commands wrap their
answer" — every command does, because every command is generated.

`PARITY.md` stops saying "waived until PR-N". Its gap section is now "What
this build cannot do", and each row names the MTProto method the pinned
Telethon has no request class for, which is the only kind of gap left.
Written for the case the release was actually built against: a pipx tlgr with
a live v1 daemon holding four accounts, and an agent parsing its JSON.

The order matters and the document says why. Stop the daemon *and* whatever
restarts it before upgrading, because two processes on one
`session.session` earns `database is locked` and then `AUTH_KEY_DUPLICATED`,
which Telegram answers by revoking the authorization. The verification is two
commands, not one: no `tlgr.daemon` process, and no open handle on the session
files — a stale socket is harmless, an open handle is not.

Then the production marker, which is a step people will otherwise discover as
a refusal: remove it, or set `TLGR_ALLOW_PRODUCTION_HOME=1` in the deployed
tlgr's own environment and nowhere else, since setting it in an interactive
shell is the same as not having the guard.

The output section lists six shapes and the one flag that sidesteps all of
them (`--results-only`), plus two answers that changed because v1's were
wrong — the bio it never fetched, and the mute it computed from the event
loop's clock. Rollback is reinstall-and-restart, with the two things that do
not roll back on their own named.
The ones a reviewer would otherwise have to reconstruct from the diff: why
three commands that the work list designs as sends are control-only (PR-10
named those four methods absent, and a property beats a default); why seven
aliases from the work list are dropped (each is the group its own operation
lives under, and placing a command there deletes the group); why `update`
joined the verb vocabulary; why the fields that carry an answer lost their
defaults (`omit_defaults` was hiding refusals); why `privacy set stories`
refuses with two pointers instead of guessing; why an absent request class is
a different gap from an absent layer; why the waiver file stopped being a
backlog; and what `ClientWrapper` became.
Both criteria that were partly met when the foundation landed are met now,
and neither was met by moving a goalpost.

§1 wanted 80 % coverage and got 77 %; it is 82 % over 13 292 tests. What
closed it was behavioural tests for the P2/P3 tails, group by group. Three
pre-foundation test modules are gone — the only three — because they drove
`ClientWrapper.get_messages`, which PR-12 deleted; the claim they were really
making moved onto `media_summary` rather than going with them.

§17 wanted `messages_core` at 95 % and got 79.6 %, with 34 ids waived to
later PRs because they were catalogued under messages while their command
home was another noun. Every one of those PRs landed: the domain is 167 of
167. The whole catalog is 1788 of 1797 and all 178 P0, which is §1.3's
condition for 2.0.0 final.

§8/§9 keep their "simulated" caveat and gain a note about where the claim now
lives: `Daemon.status()` went with `ClientWrapper`, and the distinction it
drew — an object existing versus a link being usable — is `AccountSession.
connected` and the per-account state.
The rule was written while the migration was in flight, so it said a domain
not yet reached is waived with the PR that closes it. Every one of those
promises is kept; what the file holds now is ids this build cannot cover, each
naming its reason and the missing method, and the gate refuses a domain waiver
outright.
@erfnzdeh
erfnzdeh merged commit da52cb2 into main Sep 4, 2026
18 of 20 checks passed
@erfnzdeh
erfnzdeh deleted the feat/pr12-settings branch September 4, 2026 13:56
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.

1 participant