Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
291 lines
17 KiB
Markdown
291 lines
17 KiB
Markdown
# Official platform verification
|
|
|
|
Official verification is the platform badge shown next to the name of a bot,
|
|
public channel or public supergroup whose identity a human reviewer has
|
|
confirmed. In this server it is exactly one boolean on one peer record:
|
|
`users.verified` or `channels.verified`. Nothing else about the peer changes.
|
|
|
|
Applications are filed through the built-in `@verifybot`, decided in the admin
|
|
panel, and the decision commits together with the flag write. The protocol edge
|
|
then makes the new flag observable: it drops the cached peer projections and
|
|
pushes the ordinary peer-refresh update to online clients.
|
|
|
|
## What this is not
|
|
|
|
The badge here is the **platform** flag: `user#b1b8cc83 verified:flags.17` and
|
|
`channel#d49f34c6 verified:flags.7`.
|
|
|
|
Telegram also has a second, unrelated mechanism — **third-party bot
|
|
verification** — where an ordinary bot that a platform operator has appointed as
|
|
a "verifier" attaches its own custom icon and description to arbitrary peers.
|
|
That is `botVerification#f93cd45c`, `botVerifierSettings#b0cd6617`,
|
|
`bots.setCustomVerification#8b89dfbd`, `user.bot_verification_icon:flags2.14?long`,
|
|
`channel.bot_verification_icon:flags2.13?long` and
|
|
`channelFull.bot_verification:flags2.17?BotVerification`.
|
|
|
|
The two are deliberately kept apart:
|
|
|
|
- an approval in this flow never writes `bot_verification*`, and never issues
|
|
`botVerifierSettings` to anybody;
|
|
- `bots.setCustomVerification` is routed and argument-checked at the RPC edge and
|
|
then refused with `403 BOT_VERIFIER_FORBIDDEN`
|
|
(`internal/rpc/bots_longtail.go`), because no bot on this deployment is a
|
|
verifier;
|
|
- a client that renders `bot_verification_icon` renders it from data this flow
|
|
never produces, so a third-party icon can neither stand in for the platform
|
|
badge nor be shadowed by it.
|
|
|
|
## TL constructors and flags (Layer 228)
|
|
|
|
Checked against the schema snapshot the server is built for,
|
|
`/tmp/td/_schema/layers/layer-228.tl`.
|
|
|
|
Platform verification:
|
|
|
|
| Constructor | Field |
|
|
| --- | --- |
|
|
| `user#b1b8cc83` | `verified:flags.17?true` |
|
|
| `channel#d49f34c6` | `verified:flags.7?true` |
|
|
| `chatInvite#5c9d3702` | `verified:flags.7?true`, `scam:flags.8?true`, `fake:flags.9?true` |
|
|
|
|
`chatInviteAlready#5a686d7c` carries a whole `Chat`, so a member's preview gets
|
|
the badge through `channel#d49f34c6` rather than through invite-level flags.
|
|
|
|
Third-party bot verification, for contrast — none of these are written by this
|
|
flow:
|
|
|
|
| Constructor / method | Field |
|
|
| --- | --- |
|
|
| `botVerification#f93cd45c` | `bot_id:long icon:long description:string` |
|
|
| `botVerifierSettings#b0cd6617` | `icon:long company:string custom_description:flags.0?string` |
|
|
| `bots.setCustomVerification#8b89dfbd` | `enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string` |
|
|
| `user#b1b8cc83` | `bot_verification_icon:flags2.14?long` |
|
|
| `channel#d49f34c6` | `bot_verification_icon:flags2.13?long` |
|
|
| `channelFull#a04e8d3a` | `bot_verification:flags2.17?BotVerification` |
|
|
|
|
`chatInvite#5c9d3702` also has `bot_verification:flags.13?BotVerification`; it is
|
|
never populated, for the same reason.
|
|
|
|
## End-to-end path
|
|
|
|
1. **`@verifybot`** (user id `1250000011`, seeded by migration `0152`) collects
|
|
the application in a step-by-step dialog: subject, category, description,
|
|
official website, optional social links, independent press links, optional
|
|
note. Commands are `/new`, `/status`, `/cancel`, `/help`.
|
|
2. **Eligibility, first gate.** The subject must be a bot, public channel or
|
|
public supergroup with a public `@username`, created or administered by the
|
|
applicant, not a built-in system entity, not already verified, and not
|
|
scam/fake/frozen/deleted. Per-applicant rate limits, an open-application cap
|
|
and a post-rejection cooldown also apply.
|
|
3. **Submission** writes `verification_applications` (status `submitted`) and an
|
|
immutable `verification_application_events` row.
|
|
4. **Admin panel** lists the queue and lets a reviewer claim, approve or reject.
|
|
Panel BFF routes are under `/api/verification/...`; the Admin API routes are
|
|
`GET /v1/verification/applications`, `.../{id}`, `.../counts`,
|
|
`POST .../{id}/claim|approve|reject` and `POST /v1/verification/revoke`.
|
|
Every decision also goes through the shared admin command journal, so it lands
|
|
in `admin_commands` / `admin_audit_logs`.
|
|
5. **Eligibility, second gate.** At approval time the target is re-loaded and
|
|
re-evaluated against a fresh snapshot, so a target that turned scam, lost its
|
|
username, was frozen or got verified by another route between filing and
|
|
review is refused. The review queue cannot launder a state the submission path
|
|
forbids.
|
|
6. **Decision transaction.** The status transition, the audit event, the
|
|
applicant-notification outbox row and the `verified` flag write on the peer
|
|
commit in one transaction (`verification.PeerVerifier` is invoked with the
|
|
store transaction taken from the context). "Approved" and "the peer carries
|
|
the badge" can never disagree.
|
|
7. **Protocol edge.** After the commit the service calls
|
|
`rpc.Router.NotifyPeerVerified(ctx, domain.Peer)`
|
|
(`internal/rpc/verification_notify.go`), which:
|
|
- drops the cached peer projections for the target
|
|
(`invalidateRPCProjectionForUser` / `invalidateRPCProjectionForChannel`);
|
|
- for a user or bot, reuses `NotifyUserModerationFlagsChanged` — the same
|
|
audience-wide, non-PTS `updateUser` fan-out the scam/fake flags use. The
|
|
audience is `ModerationFlagAudience` (accounts that already see the peer),
|
|
filtered to the ones currently online; each recipient gets the peer
|
|
re-projected for itself;
|
|
- for a channel, reuses `NotifyChannelChanged` →
|
|
`channelStateMutationUpdates` → `pushChannelStateToMembersWithLinkedMonoforum`,
|
|
i.e. `updateChannel` plus the refreshed `channel#d49f34c6` object to the
|
|
channel's members (and the linked monoforum when there is one);
|
|
- reports a clear error instead of panicking when the peer cannot be resolved,
|
|
and is a no-op on a nil receiver. A push failure never invalidates the
|
|
committed decision; the caller logs it and moves on.
|
|
8. **Applicant notification.** `@verifybot` messages the applicant from a durable
|
|
outbox drained by a retrying worker, never from inside the decision
|
|
transaction. Kinds are `approved`, `rejected`, `revoked`.
|
|
9. **What the client sees.** An online client applies the flag from the pushed
|
|
`User`/`Channel` object immediately: the badge appears in the dialog list,
|
|
profile, search results and message headers without a restart. An offline
|
|
client converges on reconnect — see below.
|
|
|
|
## Offline convergence
|
|
|
|
`updateUser` and `updateChannel` carry no `pts`, so they are not stored as
|
|
message-box events and are not replayed by `updates.getDifference`. Offline
|
|
sessions converge because `verified` is part of the peer's **base read model**,
|
|
whose version is bumped by the triggers shipped in `0001_init`:
|
|
|
|
- `users.verified` is listed in `telesrv_notify_user_base_read_model` (trigger
|
|
`users_read_model_changed`), which bumps `user_base`, `contact_account` and the
|
|
private dialog-light models, and in
|
|
`telesrv_notify_user_channel_participants_read_model` (trigger
|
|
`users_channel_participants_read_model_changed`), which bumps
|
|
`channel_participants`;
|
|
- `channels` bumps `channel_base` on every row change (trigger
|
|
`channels_read_model_changed`) and additionally fires
|
|
`pg_notify('telesrv_channel_changed')`.
|
|
|
|
The `user_base` notification is consumed by the read-model listener
|
|
(`internal/store/postgres/read_model_listener.go`), which invalidates the RPC
|
|
projections **and** the shared Redis `user:base` row across instances. So any
|
|
later authoritative read — `users.getUsers`, `users.getFullUser`,
|
|
`channels.getChannels`, `channels.getFullChannel`, `messages.getDialogs`, or the
|
|
`users`/`chats` vectors attached to a `getDifference` answer — already carries the
|
|
new flag. No migration is needed for this, and none was added.
|
|
|
|
## Where the flag surfaces
|
|
|
|
`verified` is projected wherever a `User` or `Channel` object is projected, which
|
|
is every one of these:
|
|
|
|
| Surface | Method | Constructor |
|
|
| --- | --- | --- |
|
|
| Dialog list | `messages.getDialogs`, `messages.getPeerDialogs` | `user`, `channel` in `users`/`chats` |
|
|
| Search | `contacts.search`, `contacts.resolveUsername`, `messages.searchGlobal`, `channels.getAdminedPublicChannels` | `user`, `channel` |
|
|
| Profile | `users.getUsers`, `users.getFullUser` | `user` in `users.userFull.users` |
|
|
| Channel info | `channels.getChannels`, `channels.getFullChannel` | `channel` in `chats` |
|
|
| Message history | `messages.getHistory`, `messages.getMessages`, channel history | `user`, `channel` in `users`/`chats` |
|
|
| Invite preview | `messages.checkChatInvite` | `chatInvite` (`verified:flags.7`) or `chatInviteAlready.chat` |
|
|
| Live updates | pushed `updates` envelopes | `updateUser` / `updateChannel` plus the peer object |
|
|
| Difference | `updates.getDifference`, `updates.getChannelDifference` | `user`, `channel` in `users`/`chats` |
|
|
|
|
The invite preview is the one that used to be missing: before, a non-member saw
|
|
an unbadged preview and the badge only appeared after joining. It is now set from
|
|
the persistent channel record in `internal/rpc/channels_invites.go`, through the
|
|
generated `Set*` helpers so the `flags` word and the struct field stay in step,
|
|
and left entirely unset for an unflagged peer.
|
|
|
|
## Migrations
|
|
|
|
- **`0153_verify_service_bot`** seeds `@verifybot` (id `1250000011`, fixed
|
|
`access_hash` double-written with `domain.VerifyBotAccessHash`), its `bots` row
|
|
and command list, and its `peer_usernames` registry entry. The handle is
|
|
occupied from the moment the schema is current, so an ordinary user cannot claim
|
|
`@verifybot` in the window before first use.
|
|
- **`0154_verification_applications`** creates
|
|
`verification_applications` (the durable audit subject, never deleted, moved
|
|
through `draft → submitted → in_review → approved|rejected|cancelled` under an
|
|
optimistic-locking `version`), the append-only
|
|
`verification_application_events` history, and
|
|
`verification_notification_outbox` with
|
|
`UNIQUE (application_id, kind)` so a repeated approve delivers one message.
|
|
Partial unique indexes enforce one live application per target and one draft per
|
|
applicant.
|
|
|
|
Neither migration touches the `verified` columns or the read-model triggers:
|
|
`users.verified` and `channels.verified` already existed and were already covered.
|
|
|
|
## Configuration
|
|
|
|
Verification (`internal/config/config.go`, `.env.example`):
|
|
|
|
| Key | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| `TELESRV_VERIFICATION_ENABLED` | `true` | Master switch. When off every use case refuses explicitly; peers already badged keep the badge. |
|
|
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | `false` | Whether plain user accounts may be subjects. |
|
|
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | `720h` | Wait before re-filing the same target after a rejection, measured from the decision. `0` disables; max `8760h`. |
|
|
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | `3` | Applications one applicant may create per window. `0` disables. |
|
|
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | `24h` | That window. |
|
|
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | `30` | `@verifybot` dialog rate per applicant, independent of applications created. `0` disables. |
|
|
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | `1m` | That window. |
|
|
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | `15s` | Applicant-notification worker cadence. Must be positive. |
|
|
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | `50` | Rows per cycle, `1..500`. |
|
|
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | `3` | Open applications per applicant. `0` disables, max `50`. |
|
|
|
|
Reviewer access:
|
|
|
|
| Key | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| `TELESRV_ADMIN_UI_PERMISSIONS` | `*` | Permissions of an Admin UI session. Reviewing needs `verification.review`; clearing an existing badge needs `verification.revoke` on top of it. |
|
|
| `TELESRV_ADMIN_SCOPED_TOKENS` | *(empty)* | `name:token:perm1,perm2` entries separated by `;`, for Admin API integrations that should get `verification.review` and nothing else. |
|
|
|
|
## Manual check with official Telegram Desktop
|
|
|
|
1. Log in to the deployment with official Telegram Desktop.
|
|
2. Open `@verifybot` — it resolves by username and its own profile already shows
|
|
the badge (the seed row is `verified`).
|
|
3. Send `/new`, pick the subject from the inline picker, and answer the steps:
|
|
category, description, official website, social links (or *Skip*), at least the
|
|
required number of independent press links, optional note. Press
|
|
*Submit application*.
|
|
4. Send `/status`; the application is listed as submitted.
|
|
5. In the admin panel open the verification queue, claim the application and
|
|
approve it. Confirm the audit entry appeared.
|
|
6. Within one notification-worker interval `@verifybot` messages the applicant
|
|
with the decision.
|
|
7. Without restarting the client, check the badge on the approved subject:
|
|
- **Profile** — open the peer's profile; the badge sits next to the title.
|
|
- **Search** — type the `@username` in global search; the result row is badged.
|
|
- **Dialog list** — the chat row in the main list is badged.
|
|
- **Message header** — open the chat; the header title is badged.
|
|
- **Invite preview** — from a *second* account that is **not** a member, open
|
|
an invite link to the approved channel. The join-confirmation box is badged
|
|
before joining. (This is the `chatInvite#5c9d3702 verified:flags.7` path.)
|
|
8. To check offline convergence, quit the client before approving, approve, then
|
|
start it again: the badge is present on the first read, delivered by
|
|
`getDifference` and the peer reads it triggers rather than by a live push.
|
|
9. Revoking from the panel takes the badge away by the same route.
|
|
|
|
## Limitations
|
|
|
|
- **Plain user accounts are off by default.** With
|
|
`TELESRV_VERIFICATION_ALLOW_USER_TARGETS=false` (the shipped default) an
|
|
application whose subject is an ordinary account is refused
|
|
(`ErrVerificationUserTargetsDisabled`). Turning it on does not add any extra
|
|
identity checks; it only stops refusing the target type.
|
|
- **Third-party bot verification is not part of this mechanism and is not
|
|
implemented.** `bots.setCustomVerification` never succeeds: it validates its
|
|
arguments and then refuses with `403 BOT_VERIFIER_FORBIDDEN`. No
|
|
`botVerifierSettings` are issued, and
|
|
`user.bot_verification_icon` / `channel.bot_verification_icon` /
|
|
`channelFull.bot_verification` / `chatInvite.bot_verification` are never
|
|
populated. A client asking for a custom verifier icon gets nothing.
|
|
- **The badge has no attributes.** It is a single boolean: no verifier company, no
|
|
custom description, no per-peer icon, no expiry, no verification tier. There is
|
|
nothing in the TL surface to carry them for the platform flag.
|
|
- **Built-in system entities cannot be applied for.** Service accounts are refused
|
|
with `ErrVerificationTargetSystem`; their badge is set by the seed migrations.
|
|
- **A subject with no public `@username` is refused**
|
|
(`ErrVerificationTargetNotPublic`), so private channels and usernameless bots
|
|
cannot be verified at all — not even by an operator using the panel's revoke
|
|
route in reverse.
|
|
- **Legacy basic groups (`chat#…`) have no `verified` field** in Layer 228, so a
|
|
non-migrated basic group can never show a badge regardless of what is stored.
|
|
- **The live push only reaches online sessions.** `NotifyPeerVerified` filters the
|
|
audience by the online index; everybody else converges on their next
|
|
authoritative read. The user fan-out is additionally bounded (the moderation
|
|
audience is capped, currently at 4096 accounts) and the channel fan-out is
|
|
bounded by the online-member index, so on a very large peer some sessions get
|
|
the flag from their next read rather than from a push.
|
|
- **`updateUser` / `updateChannel` carry no `pts`.** They are not persisted as
|
|
message-box events, so a session that was offline during the decision never
|
|
replays the push itself; it depends on the read-model bump. That is by design
|
|
(a badge change is not a message), but it means the badge is not guaranteed to
|
|
arrive as an *event* — only as *state*.
|
|
- **The live push can be one beat behind the shared base-user cache.** The
|
|
decision writes the user row inside the verification transaction, bypassing the
|
|
`users` service and therefore its Redis `user:base` refresh; that cache is
|
|
dropped cross-instance by the asynchronous `user_base` read-model
|
|
notification. `NotifyPeerVerified` runs synchronously right after commit, so in
|
|
the small window before the listener processes the event the pushed `user`
|
|
object can still carry the pre-decision flag. The persisted state is always
|
|
correct, the projections are always invalidated, and the client repairs itself
|
|
on the next read, so this shows up at worst as a badge that appears a moment
|
|
late rather than instantly. The channel path is not affected: the
|
|
transaction-scoped channel store is handed the channel row cache and drops it
|
|
on the flag write.
|
|
- **Nothing re-checks a verified peer over time.** Once badged, a peer keeps the
|
|
badge until an operator revokes it. There is no periodic re-validation, and
|
|
losing the username or picking up a scam flag later does not clear it.
|