feat: add NFT usernames and bot verification (#22)

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>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

359
docs/bot_verification.md Normal file
View file

@ -0,0 +1,359 @@
# Third-party bot verification
Third-party verification is an **attributed** mark: a bot that the operator
appointed as a *verifier* attaches its own custom-emoji icon and one line of
description to a peer. Official clients draw that icon **before** the peer's name
and show the description in the profile, together with the name of the company the
verifier vouches under.
Reference material:
- <https://core.telegram.org/api/bots/verification>
- <https://telegram.org/verify#third-party-verification>
Applications are collected by the built-in `@verifierbot`, decided in the admin
panel, and the decision commits together with the mark write. The protocol edge then
drops the cached peer projections and pushes the ordinary peer-refresh update, so an
online client shows the icon without a restart.
## What this is not
This is **not** the platform checkmark. That one is a single boolean on the peer
(`users.verified` / `channels.verified`), granted by the operator after platform
review, collected by `@verifybot`, and documented in [`verification.md`](verification.md).
The two mechanisms are deliberately disjoint:
| | Official verification | Third-party verification |
| --- | --- | --- |
| Stored as | `users.verified`, `channels.verified` (boolean) | `bot_verifier_settings`, `custom_verifications` (attributed rows) |
| Granted by | the platform operator | a verifier bot the operator appointed |
| Rendered as | the standard checkmark **after** the name | the verifier's custom emoji **before** the name, plus a profile description |
| Front door | `@verifybot` | `@verifierbot` (or `bots.setCustomVerification` directly) |
| Panel section | *Official verification* (`/verification`) | *Third-party verification* (`/bot-verification`) |
| Permissions | `verification.review`, `verification.revoke` | `botverification.review`, `botverification.manage` |
Neither reads the other's tables. Both can sit on one peer at the same time, an
approval on one side never writes the other side's state, and revoking one leaves
the other alone. The admin panel repeats that distinction in the section header and
on every decision page, because "verified" in a ticket almost always means the other
one.
## TL constructors and flags (Layer 228)
Checked against the schema snapshot the server is built for,
`/tmp/td/_schema/layers/layer-228.tl`.
| Constructor / method | Field |
| --- | --- |
| `botVerification#f93cd45c` | `bot_id:long icon:long description:string` |
| `botVerifierSettings#b0cd6617` | `can_modify_custom_description:flags.1?true 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 = Bool` |
| `user#b1b8cc83` | `bot_verification_icon:flags2.14?long` |
| `channel#d49f34c6` | `bot_verification_icon:flags2.13?long` |
| `userFull#6cbe645` | `bot_verification:flags2.12?BotVerification` |
| `channelFull#a04e8d3a` | `bot_verification:flags2.17?BotVerification` |
| `chatInvite#5c9d3702` | `bot_verification:flags.13?BotVerification` |
| `botInfo#4d8a0299` | `verifier_settings:flags.9?BotVerifierSettings` |
One fact — "verifier *B* marked peer *P* with icon *I* and description *D*" — is
spread over six unrelated constructors, and a client renders the badge only when the
exact bit is set. Every projection therefore goes through the generated `Set*`
helpers (`internal/rpc/bot_verification_projection.go`): a struct field assigned
without its flag bit encodes as an absent field, and the badge silently disappears.
Note the asymmetry inside `botVerifierSettings`: `custom_description:flags.0` is the
operator-configured *default* description, while
`can_modify_custom_description:flags.1` is the permission that lets the verifier
override it per peer. `botVerification.description` is the resolved text actually
shown on a marked peer.
## The icon is a custom emoji document
`botVerification.icon` and `botVerifierSettings.icon` are custom emoji **document
ids**. A client resolves them through `messages.getCustomEmojiDocuments` — exactly
the reader `files.Service.GetDocuments` answers from on this server.
Consequences that shape the whole feature:
- An id that names no fetchable document renders as **nothing at all**: the peer is
marked in the database and the client draws an empty space. Nothing errors, nothing
logs on the client, and the operator sees a granted mark that users cannot see.
- Therefore the icon is never a free-form number. `verification_icons` is a
catalogue, `botverification.Service.UpsertIcon` resolves the document before
writing the row and refuses anything that is not a custom emoji
(`domain.Document.IsCustomEmoji`), and a grant may only reference a catalogue entry
that is `active` and either shared or reserved for that bot
(`VerificationIcon.UsableBy`).
- The mark denormalises the icon at grant time (`custom_verifications.icon_document_id`),
so a verifier changing its own icon later does not silently re-brand the peers it
already marked.
The panel exposes the same rule: the icon catalogue tab is where document ids are
registered and named, and the grant form only offers active catalogue entries.
## End-to-end path
1. **Icon catalogue.** An operator adds a custom emoji document to
`verification_icons` (panel: *Third-party verification → Icon catalogue*, or
`POST /api/actions/upsert-verification-icon`). Entries can be shared or reserved
for one bot, and retiring an entry (`set-verification-icon-active`) stops new
grants without touching marks that already carry it.
2. **Verifier status.** The operator grants a bot verifier status — an icon from the
catalogue, a company name, an optional default description and
`can_modify_custom_description` (panel: *Verifiers*, or
`POST /api/actions/grant-bot-verifier`). The row in `bot_verifier_settings` *is*
verifier status: it is the only authority `bots.setCustomVerification` consults,
and it is projected as `botInfo.verifier_settings`. Nothing seeds it — not even
migration `0155` for the built-in bot — because seeding verifier status would ship
a badge printer with the schema.
3. **Two ways to reach a mark.**
- **Direct RPC.** The verifier bot (or the user who owns it) calls
`bots.setCustomVerification`. `internal/rpc/bots_longtail.go` resolves the two
TL branches — `bot:flags.0` unset means "the caller is the bot", set means "a
user acting through a bot it owns" — validates shape, and hands a
`domain.SetCustomVerificationRequest` to the service. A missing or disabled
verifier row answers `403 BOT_VERIFIER_FORBIDDEN`, and the error deliberately
does not distinguish "never was a verifier" from "switched off".
- **Application queue.** A peer owner talks to `@verifierbot`
(`/verify`, `/status`, `/revoke`, `/cancel`, `/help`), picks one of their own
bots, channels or their own account, states a reason and optionally a wanted
description. The bot writes `custom_verification_requests` with status
`pending`; a partial unique index keeps one pending row per
(verifier, peer) pair. `@verifierbot` decides nothing — it says so in `/start`.
4. **Review.** The panel lists the queue, the verifier roster, the icon catalogue and
every granted mark. BFF routes are `GET /api/botverification/{verifiers,icons,marks,requests,counts}`,
`GET /api/botverification/requests/{id}` and
`POST /api/botverification/requests/{id}/{approve,reject,revoke}`; the manage-only
mutations are the `/api/actions/...` commands listed above plus
`set-bot-verifier-enabled`, `revoke-bot-verifier` and
`revoke-custom-verification`. Every mutation goes through the shared admin command
journal (reason → dry run → confirm), so it lands in `admin_commands` /
`admin_audit_logs`.
5. **Second gate at approval.** `botverification.Service.Approve` re-loads a fresh
snapshot: the verifier must still exist and be enabled, the peer must still
resolve, and the per-verifier quota is spent only when the approval would create a
mark rather than update one. A queue that sat for days cannot launder a state the
RPC path would refuse. `version` is an optimistic lock — a stale panel gets a
`409` and the page reloads instead of overwriting a fresher decision.
6. **Decision transaction.** The status transition and the mark write commit
together: `DecideCustomVerificationRequest` runs the grant (or the revoke)
through a callback whose context carries the decision's own transaction. "Approved"
and "the peer carries the mark" can never disagree. The description is resolved by
`BotVerifierSettings.DescriptionFor` — the applicant's wording only when
`can_modify_custom_description` is set, otherwise the operator default — which is
the single place that rule lives, so the RPC edge, the bot dialog and the panel
preview cannot drift.
7. **Protocol edge.** After the commit the service calls
`rpc.Router.NotifyPeerBotVerification(ctx, domain.Peer)`
(`internal/rpc/bot_verification_notify.go`), which:
- drops the cached peer projections for the peer, and for a channel also the
`channelFull` bot-info cache that carries `botInfo.verifier_settings`;
- for a user or bot, reuses `NotifyUserModerationFlagsChanged` — the audience-wide,
non-PTS `updateUser` fan-out the scam/fake flags use, filtered to online
sessions, with the peer re-projected per recipient;
- for a channel, reuses `NotifyChannelChanged``updateChannel` plus the refreshed
`channel#d49f34c6` object to members (and a linked monoforum when there is one);
- is a no-op on a nil receiver and reports an error rather than panicking. A push
failure never invalidates the committed decision.
8. **Applicant notification.** `@verifierbot` messages the applicant with the
outcome (`SendVerificationDecision`). `internal_note` is never rendered there
under any status — only `decision_reason` reaches the applicant.
9. **What the client sees.** The icon appears before the name in the dialog list,
search results, message headers and the profile, and the description appears in the
profile. `bots.setCustomVerification` returns `BoolTrue` for every successful
application, including an idempotent re-apply or revoke; official clients
treat `BoolFalse` as failure.
## Where the mark surfaces
| Surface | Method | Field |
| --- | --- | --- |
| Dialog list, search, history, difference | `messages.getDialogs`, `contacts.search`, `contacts.resolveUsername`, `messages.getHistory`, `updates.getDifference`, … | `user.bot_verification_icon`, `channel.bot_verification_icon` |
| User profile | `users.getFullUser` | `userFull.bot_verification` |
| Channel / supergroup info | `channels.getFullChannel` | `channelFull.bot_verification` |
| Invite preview (non-member) | `messages.checkChatInvite` | `chatInvite.bot_verification` |
| Verifier bot's own profile | `users.getFullUser`, `channels.getFullChannel` bot list | `botInfo.verifier_settings` |
| Live updates | pushed `updates` envelopes | `updateUser` / `updateChannel` plus the peer object |
The icon overlay runs at the **response boundary**, not inside `tgUser`/`tgChannel`:
`applyPeerReadModels` (`internal/rpc/story_peer_projection.go`) is the single hook
every handler funnels through, so all ~40 call sites get the field with one batched
read per response instead of an N+1 per peer. The `userFull` / `channelFull` /
`chatInvite` variants are post-cache overlays for the same reason — a cached full
object is still stamped with the current mark. A nil service or any read error leaves
every flag unset, which is byte-identical to the pre-feature wire shape.
## Migrations
- **`0155_bot_verification`** creates the four tables:
- `verification_icons` — the catalogue. `document_id` is unique and positive,
`owner_bot_id = 0` means shared, `active` retires an entry without deleting it.
- `bot_verifier_settings` — verifier status, keyed by `bot_id` with an optimistic
`version`, `enabled` as the per-verifier kill switch, and the operator's
`granted_by` / `grant_reason` for the audit trail.
- `custom_verifications` — granted marks. `UNIQUE (peer_type, peer_id)`
matches the single `BotVerification` value on the wire: a different verifier
replaces the current mark rather than leaving hidden fallback rows. It also has
`peer_type IN ('user','channel')`,
`icon_document_id` denormalised from the verifier, `ON DELETE CASCADE` from the
verifier row.
- `custom_verification_requests` — the review queue. `status IN ('pending','approved','rejected','revoked')`,
a partial unique index for one `pending` row per (verifier, peer), and check
constraints that pair each stamp with its status
(`(status = 'approved') = (approved_at IS NOT NULL)`) and refuse a rejection
without a reason.
- **`0156_verifier_service_bot`** seeds `@verifierbot` (id `1250000013`, fixed
`access_hash` double-written with `domain.VerifierBotAccessHash`), its `bots` row
and command list, and its `peer_usernames` registry entry, so the handle is occupied
from the moment the schema is current. `verified = false` on purpose: a third-party
verifier wearing the platform checkmark would blur the exact distinction it has to
explain to every applicant. The seed grants **no** verifier status — an operator
does that by hand in the panel.
Neither migration adds read-model triggers: the marks are read live at the response
boundary rather than cached in a peer read model.
## Configuration
Third-party verification (`internal/config/config.go`, `.env.example`):
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_BOT_VERIFICATION_ENABLED` | `true` | Master switch. When off, every third-party mutation is refused (grants, revocations, applications, catalogue edits) while marks already granted keep rendering — blanking one verifier's badges is what its per-verifier kill switch is for. |
| `TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` | `10000` | Peers one verifier may mark. `0` disables the service bound and leaves only the storage bound (`domain.MaxCustomVerificationsPerVerifier`), which is also the maximum this key accepts. |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT` | `5` | Applications one applicant may file per window, across all verifier bots. `0` disables the budget. Looser than the official `3` on purpose: a deployment can run several verifier companies, and filing with a second one is not a retry of the first. |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW` | `24h` | That window. A positive limit requires a positive window. |
Operator access:
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_ADMIN_UI_PERMISSIONS` | `*` | Permissions of an Admin UI session. Reading the section and deciding applications needs `botverification.review`; the verifier roster, the icon catalogue and stripping a granted mark need `botverification.manage`. |
| `TELESRV_ADMIN_SCOPED_TOKENS` | *(empty)* | `name:token:perm1,perm2` entries separated by `;`, for integrations that should get `botverification.review` and nothing else. |
The two rights are independent of the official ones: a reviewer may hold
`verification.review` without `botverification.review`, and vice versa. The panel
hides the nav entry, gates the route and hides the manage-only buttons accordingly;
every route is checked again server-side.
## Manual check
### Telegram Desktop
1. In the panel, open *Third-party verification → Icon catalogue* and add a custom
emoji document id. The *Emoji* section lists the documents this deployment holds
with their ids; pick one that a client can actually fetch.
2. Open *Verifiers*, grant `@verifierbot` verifier status with that icon, a company
name (say `Acme Verification Ltd`), a default description
(`Verified by Acme`) and `can_modify_custom_description` off for the first pass.
Confirm the audit entry appeared.
3. Log in to the deployment with official Telegram Desktop and open `@verifierbot`.
Its profile now carries a **"verified by" block** built from
`botInfo.verifier_settings` — the company and the icon — while its name has **no**
platform checkmark. That contrast is the point.
4. Send `/verify`, pick one of your channels from the inline picker, state a reason,
confirm. `/status` lists the application as pending.
5. In the panel open the queue, open the application, read the *Description the mark
would carry* preview (with `can_modify_custom_description` off it shows the
verifier default, not what you asked for), and approve.
6. Within a moment `@verifierbot` messages you the decision.
7. Without restarting the client, check the icon on the approved channel:
- **Profile** — the icon sits immediately **before** the title, and the
description line ("Verified by Acme") appears in the profile body
(`channelFull.bot_verification`).
- **Dialog list** — the chat row shows the icon before the title.
- **Message header** — open the chat; the header title carries the icon.
- **Search** — type the `@username` in global search; the result row carries it.
- **Invite preview** — from a second account that is **not** a member, open an
invite link to that channel: the join box carries the icon
(`chatInvite.bot_verification`).
8. If the peer also holds the platform checkmark, both are visible at once: the
custom icon before the name, the checkmark after it.
9. In the panel, revoke from the application's danger zone (or *Granted marks →
Remove mark*). The icon disappears from all of those surfaces on the next push or
read, and the platform checkmark stays untouched.
10. To check the invisible-badge failure mode on purpose, retire the icon and grant
a verifier a catalogue entry whose document was deleted: the peer is marked in
the database and the client draws nothing. That is why the catalogue validates
documents up front.
### Telegram Android
1. Log in with the official Android client, force-close it and reopen it after the
grant so the profile cache is cold.
2. `@verifierbot` profile: the verifier block ("verified by *company*" with the
icon) is rendered under the bot's info, and the bot's name has no checkmark.
3. Approve an application for a **user account** (your own) and open that account's
profile from a second device: the icon is drawn before the name in the profile
header and in the chat header, and the description is a line in the profile
(`userFull.bot_verification`).
4. Chat list and global search rows carry the icon before the name
(`user.bot_verification_icon`).
5. Custom emoji rendering follows the client's animated-emoji setting: with animated
emoji disabled the icon shows as a static frame, and while the document is still
being fetched the slot is briefly empty. Neither is a server-side problem.
6. Revoke from the panel and pull-to-refresh the profile: the icon is gone.
## Limitations
- **A verifier can mark a peer that never asked.** `bots.setCustomVerification`
authorises the *caller* (the bot itself, or a user who owns it) and the *verifier
status*, not the target's consent. Verifier status is the trust boundary; that is
why granting it is an operator-only action, why it has a kill switch, and why
`TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` bounds it. A peer cannot refuse or
remove a mark itself — only the verifier (`/revoke` in the bot dialog, or the RPC
with `enabled` unset) or an operator can.
- **No per-application event history.** Unlike official verification, there is no
`*_events` table: an application keeps only `decided_by`, `decision_reason`,
`internal_note` and its stamps. The full trail lives in the shared
`admin_commands` / `admin_audit_logs` journal, so the panel's decision page shows a
decision, not a timeline.
- **A revocation clears `approved_at`.** `0155` pairs each stamp with its status, so
leaving the approved state nulls the approval stamp. After a revoke, "when was this
approved?" can only be answered from the audit journal.
- **Applicant notifications are best-effort.** They are sent directly by
`@verifierbot` after the decision commits, not through a durable outbox like the
official flow's `verification_notification_outbox`. A delivery failure is logged
and swallowed (`notifyApplicant`); the decision itself stands, and nothing retries
the message, so an applicant can end up with a decided application they were never
told about.
- **Only users and channels can be marked.** `peer_type` is constrained to
`user`/`channel`, matching the TL surface: legacy basic groups (`chat#…`) have no
`bot_verification` field in Layer 228, so a non-migrated basic group can never
show a mark.
- **Only bots can be verifiers, and system bots cannot** — except the built-in
`@verifierbot`. `botInfo.verifier_settings` exists only on a bot, so a user account
granted verifier status would carry a status no client can see; seeded service
accounts are refused outright (`@verifybot` in particular, which owns the *other*
mechanism).
- **One icon per verifier, one mark per peer.** A verifier cannot vary
its icon per peer, there are no verification tiers, and no expiry: a mark lives
until somebody removes it. Nothing re-validates a marked peer over time — losing
its username or picking up a scam flag later does not clear the mark.
- **A new verifier replaces the current peer mark.** `user.bot_verification_icon`
and `channel.bot_verification_icon` are single `long` fields, so the database
stores one matching mark. Replacing it cannot leave an older hidden mark that
unexpectedly reappears after a revoke or kill-switch change.
- **A retired icon keeps rendering on existing marks.** Retiring a catalogue entry
only blocks new grants, because the mark copied the document id at grant time.
Blanking an already-granted icon means revoking the marks (or the verifier).
- **An unresolvable document is an invisible badge.** The catalogue validates the
document when the entry is written, not continuously. A document deleted afterwards
leaves marks that render as nothing, and the server has no way to notice.
- **The live push only reaches online sessions**, and the user fan-out is bounded by
the same capped moderation audience the scam/fake flags use. Everybody else
converges on their next authoritative read, which is always correct: the icon is an
overlay read live at the response boundary rather than a cached read-model column.
- **`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; it picks the mark up as *state* on its next read, not as an *event*.
- **`channelFull`'s bot-info cache is per process.** `NotifyPeerBotVerification`
drops it on the instance that handled the decision. On other instances a cached
`channelFull.bot_info` can still carry a stale `verifier_settings` block (the
company/icon *of the verifier bot*, not the peer's mark) until that entry expires.
The peer's own `bot_verification` fields are overlaid post-cache and are not
affected.
- **`TELESRV_BOT_VERIFICATION_ENABLED=false` is not a badge switch.** It refuses new
mutations; the marks already granted keep being projected. Use the per-verifier kill
switch, or revoke, to actually clear badges.

View file

@ -60,6 +60,8 @@ This document describes every setting loaded by `internal/config`. Defaults and
| `TELESRV_ADMIN_UI_PASSWORD` | secret string / empty | Admin UI login password. Configure this or `TELESRV_ADMIN_UI_TOKEN`. |
| `TELESRV_ADMIN_UI_TOKEN` | secret string / empty | Alternative Admin UI login credential. Admin write calls still use the separate `TELESRV_ADMIN_API_TOKEN`. |
| `TELESRV_ADMIN_SESSION_KEY` | secret string / empty | Encrypts/signs Admin UI session cookies. Production should use at least 32 random bytes; changing it invalidates sessions. |
| `TELESRV_ADMIN_UI_PERMISSIONS` | comma-separated list / `*` | Permissions granted to an Admin UI session authenticated with `TELESRV_ADMIN_UI_PASSWORD` / `_TOKEN`. `*` grants every permission and is the default, so enabling RBAC never locks an operator out of a panel that worked before. Names use letters, digits and `._:-`, at most 64 characters, and may end in `namespace.*` to grant a whole namespace. An empty list or an unparsable name fails startup. |
| `TELESRV_ADMIN_SCOPED_TOKENS` | `name:token:perm1,perm2` entries separated by `;` / empty | Additional Admin API bearer tokens carrying a bounded permission set each, so an integration gets exactly the rights it needs instead of the unrestricted `TELESRV_ADMIN_API_TOKEN`. A token may contain neither `:` nor whitespace, every entry must list at least one permission, names and tokens must be unique, and reusing `TELESRV_ADMIN_API_TOKEN` as a scoped token is refused because it would silently widen it to every permission. Any malformed entry fails startup rather than silently granting or dropping rights. |
| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | Client-visible canonical public-link root. Paths are allowed; credentials, query, and fragment are rejected. Local example: `http://127.0.0.1:2401`. |
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. |
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. |
@ -540,6 +542,116 @@ path. `TELESRV_PUBLIC_BASE_URL` must resolve to that proxy for moderation freeze
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | Delay snapshotted into `can_craft_at`. |
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | Per-input local craft success contribution, capped at 1000 permille. |
### Composite account rating and collectible usernames
The account rating is a server-local admin score combining Stars received and spent, bounded account activity, and
moderation penalties. It is intentionally **not** projected into Telegram's `userFull.stars_rating` or
`stars_my_pending_rating`: those fields represent official Stars transaction-volume semantics, which this composite
does not implement. Every component is stored separately so operators can explain and reproduce a level. Collectible
(NFT) usernames are minted by the operator; no external marketplace, wallet or chain node is configured or contacted.
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_RATING_ENABLED` | bool / `true` | Enables the local admin composite rating. Disabled refuses rating writes; client-facing Telegram rating fields remain unset in either mode. |
| `TELESRV_RATING_PENDING_DELAY` | duration / `24h` | How long a local rating increase stays pending before it becomes the visible admin level. A decrease is always applied immediately, so a penalty is never delayed. `0` applies every change at once; must be `0..720h`. |
| `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | Background recompute worker interval; must be positive. |
| `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | Stale projections recomputed per cycle; must be `1..10000`. |
| `TELESRV_RATING_STALE_AFTER` | duration / `6h` | Projection age after which the worker recomputes a user; must be positive. |
| `TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE` | int64 / `1000` | Weight of Stars credited to the account (gifts, reactions, paid messages received), in permille of the raw amount. |
| `TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE` | int64 / `250` | Weight of Stars the account spent, in permille. Spending is a weaker signal than receiving. |
| `TELESRV_RATING_WEIGHT_MESSAGE_SENT` | int64 / `1` | Score per sent message. |
| `TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY` | int64 / `2` | Score per day of account age. |
| `TELESRV_RATING_WEIGHT_GIFT_RECEIVED` | int64 / `25` | Score per collectible gift held. |
| `TELESRV_RATING_WEIGHT_MODERATION_CASE` | int64 / `150` | Penalty magnitude per upheld moderation case; the formula subtracts it. |
| `TELESRV_RATING_WEIGHT_SCAM_PENALTY` | int64 / `5000` | Flat penalty magnitude for the scam flag. |
| `TELESRV_RATING_WEIGHT_FAKE_PENALTY` | int64 / `5000` | Flat penalty magnitude for the fake flag. |
| `TELESRV_RATING_ACTIVITY_CAP` | int64 / `5000` | Upper bound of the activity component so activity alone cannot outweigh Stars and moderation; `0` leaves it uncapped. |
| `TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE` | URL template / empty | Landing URL recorded on a minted collectible username when the mint command carries no explicit URL. Empty derives `<TELESRV_PUBLIC_BASE_URL>/nft/username/<username>`. A configured template must be an absolute http(s) URL without userinfo; it may carry the `{username}` placeholder, and without it the name is appended as the last path segment. |
All rating weights are non-negative magnitudes and are validated even when the feature is disabled, so enabling it
later is not the moment a typo is discovered. The defaults above are exactly the shipped domain formula, so behaviour
is identical whether or not these keys are set. The final score is clamped at zero: penalties can erase a rating but
never invert it.
**Collectible username prices are stored in the smallest units of their currency**, because that is what
`fragment.collectibleInfo` carries: `amount` is "the total price in the smallest units of the currency (integer, not
float/double)" and `crypto_amount` likewise. So `USD 1000` is ten dollars, `TON 900` is 900 nanotons, and `XTR 1000` is
a thousand Stars, since Stars have no subunit. Clients divide by that exponent before drawing the price. The admin panel
is the conversion boundary: prices are typed and displayed there in whole currency units and converted on the way to the
API, so an operator never has to count zeros. An integration writing to `/api/actions/mint-collectible-username`
directly is talking to the API, not the panel, and must send smallest units itself.
### Official platform verification
Official verification is the platform badge (`user.verified` / `channel.verified`): an application is filed through the
built-in `@verifybot`, decided in the admin panel, and an approval flips that one flag on that one peer record. It is
deliberately not the third-party `botVerification` icon, where an outside organisation attaches its own mark. The
application row is the durable audit subject and is never deleted, only moved through its status machine.
Every eligibility check runs twice: once when the application is filed and again, against a freshly loaded snapshot, at
the moment of approval. A target must exist, carry a public username, be controlled by the applicant (bot owner, or
channel creator/administrator), not already be verified, not be deleted/frozen/scam/fake, and not be a built-in system
entity. A target that changed between submission and review is refused at the second gate, so the review queue cannot
be used to grant a state the submission path forbids. The flag itself is written inside the decision transaction, so
"approved" and "target verified" commit together.
Submitted links (website, social, press) are validated as plain http(s) URLs to public hosts: credentials, non-web
schemes, non-standard ports, loopback, link-local, private and other reserved address space are all refused. **The
server never fetches a submitted link** — not at submission, not during review, not from the admin panel. That is a
deliberate anti-SSRF decision, and validation is the only thing ever done to an applicant-controlled URL.
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_VERIFICATION_ENABLED` | bool / `true` | Enables official verification. Disabled refuses every verification use case explicitly; peers already carrying the badge keep it, because the flag lives on the peer record. |
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | bool / `false` | Accepts plain user accounts as verification subjects. Off by default: the official process verifies a public presence (bot, public channel, public supergroup), and a private account has nothing to check. |
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | duration / `720h` | Wait imposed on an applicant/target pair after a rejection, measured from the decision so a slow review never shortens it. `0` disables it; must be `0..8760h`. |
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | int / `3` | Applications one applicant may create per window. `0` disables the budget; must be non-negative. |
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | duration / `24h` | Window for the creation budget. Must be positive whenever the limit is set: a positive limit with a zero window is a limiter that never refills. |
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | int / `30` | `@verifybot` dialog rate per applicant, independent of how many applications are actually created. `0` disables it. |
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | duration / `1m` | Window for the bot dialog rate; must be positive whenever that limit is set. |
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | duration / `15s` | Applicant-notification worker interval; must be positive. A decision commits with its outbox row, never with a message send, so delivery is a separate retrying cycle over durable rows. |
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | int / `50` | Outbox rows delivered per cycle; must be `1..500`. |
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | int / `3` | Applications one applicant may keep open (draft, submitted, in review) at once. `0` disables the cap; must be `0..50`. |
The defaults ship the feature on with the official bar in place, so no existing deployment changes behaviour: user
accounts are not accepted, a rejection costs a month, and an applicant can neither flood the queue nor keep an
unbounded number of applications open. Every value is validated even when the feature is disabled, so enabling it
later is not the moment a typo is discovered.
### Third-party bot verification
Third-party verification is the other badge (`botVerification`, projected onto `user.bot_verification_icon`,
`channel.bot_verification_icon`, `userFull.bot_verification`, `channelFull.bot_verification`,
`chatInvite.bot_verification`, and advertised by `botInfo.verifier_settings`): an outside organisation running a
**verifier bot** marks peers with its own icon and description. The operator grants verifier status to a bot; the bot
then applies its mark through `bots.setCustomVerification`, or through the application queue its own dialog drives. It is
never a second route to the platform checkmark above, and the two mechanisms never read or write each other's state.
The icon is a custom emoji **document id**, and clients resolve it through `messages.getCustomEmojiDocuments`. An id
that names no fetchable custom emoji document therefore renders as *nothing at all*: the badge is invisible, the peer
looks unverified, and the only place the mark exists is the database. That is why the icon catalogue is operator-curated
and validated against real documents before anything is written — both when an entry is added and when a verifier is
granted an icon.
Every mutation re-derives "may this bot verify right now?" from the stored verifier row, so the per-verifier kill switch
takes effect immediately on the RPC path *and* on the review path: an application approved after the switch was flipped
is refused rather than granted. A verifier bot may only be driven by itself or by its owner, and an applicant may only
file for a peer it controls (bot owner, channel creator, or an administrator carrying `change_info`). Approvals and
revocations move the mark inside the store's decision transaction, so an approved application can never exist without
its mark.
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_BOT_VERIFICATION_ENABLED` | bool / `true` | Enables third-party bot verification. Disabled refuses every mutation explicitly (grants, revocations, applications, catalogue edits) while marks already granted keep projecting: blanking one verifier's badges is what its per-verifier kill switch is for. A deployment that wants the pre-feature wire shape leaves the service unwired instead. |
| `TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` | int / `10000` | Peers one verifier bot may mark. Verifier status is granted per deployment rather than earned per peer, so an unbounded verifier would be an unbounded badge printer. Spent only on a *new* mark — an existing one stays re-describable at the bound. `0` disables the service bound and leaves only the storage bound, which is also the maximum accepted (`10000`). |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT` | int / `5` | Verification applications one applicant may file per window, across all verifier bots. Spent last among the creation checks, so a refused application costs no budget. `0` disables it; must be non-negative. |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW` | duration / `24h` | Window for the application budget. Must be positive whenever the limit is set: a positive limit with a zero window is a limiter that never refills. |
The application budget is deliberately looser than the official one (`TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3`): a
deployment can run several verifier companies, and filing with a second one is not a retry of the first. Both keys are
validated even when the feature is disabled, and the per-verifier bound is checked against the storage bound, so a key
that cannot do what it says fails startup instead of being silently unreachable.
## 11. Private calls, group calls, TURN, SFU, and livestream
| Setting | Type / code default | Description and constraints |

View file

@ -517,6 +517,58 @@ active key。不要手工编辑 manifest 或 PEM不要在各实例上分别
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | 签发时固化到 `can_craft_at` 的等待期;可 Craft 礼物即使为 `0s` 也写升级时间这一正数能力边界0 只表示不具备 Craft 能力或已终结。 |
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | 每份输入礼物贡献的本地合成成功概率,累计上限 1000‰。 |
### 本地账号评分与 collectible username
账号评分是供管理后台使用的本地风控/信誉复合分,组合 Stars 收支、账号活跃和管理处罚。它**不会**投影到 Telegram 的 `userFull.stars_rating` / `stars_my_pending_rating`:官方字段表达 Stars 交易量当前复合公式不具备同等语义。Collectible username 由管理员签发,本功能不访问外部市场、钱包或区块链节点。
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_RATING_ENABLED` | bool / `true` | 启用本地后台复合评分;关闭时拒绝评分写入,两种模式都不设置客户端官方 Stars Rating 字段。 |
| `TELESRV_RATING_PENDING_DELAY` | duration / `24h` | 本地评分上涨进入可见后台等级前的等待期;下降立即生效。允许 `0..720h``0` 表示立即应用。 |
| `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | 后台重算周期,必须为正数。 |
| `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | 每轮重算的 stale projection 数,必须为 `1..10000`。 |
| `TELESRV_RATING_STALE_AFTER` | duration / `6h` | 超过该年龄的评分进入重算,必须为正数。 |
| `TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE` | int64 / `1000` | Stars 收入权重(千分比)。 |
| `TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE` | int64 / `250` | Stars 支出权重(千分比)。 |
| `TELESRV_RATING_WEIGHT_MESSAGE_SENT` | int64 / `1` | 每条已发送消息贡献分。 |
| `TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY` | int64 / `2` | 每个账号存续日贡献分。 |
| `TELESRV_RATING_WEIGHT_GIFT_RECEIVED` | int64 / `25` | 每份持有 collectible gift 贡献分。 |
| `TELESRV_RATING_WEIGHT_MODERATION_CASE` | int64 / `150` | 每个成立管理案件的扣分幅度。 |
| `TELESRV_RATING_WEIGHT_SCAM_PENALTY` | int64 / `5000` | scam 标记固定扣分。 |
| `TELESRV_RATING_WEIGHT_FAKE_PENALTY` | int64 / `5000` | fake 标记固定扣分。 |
| `TELESRV_RATING_ACTIVITY_CAP` | int64 / `5000` | 活跃分上限;`0` 表示不封顶。 |
| `TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE` | URL template / 空 | 管理员未显式给 URL 时写入资产的落地页模板;空值派生 `<TELESRV_PUBLIC_BASE_URL>/nft/username/<username>`。配置值须为无 userinfo 的绝对 http(s) URL可含 `{username}`;无占位符时把 username 追加为最后路径段。 |
`fragment.collectibleInfo.amount``crypto_amount` 使用币种最小单位:例如 USD 1000 表示 10 美元TON 900 表示 900 nanotonsXTR 无子单位。后台 UI 负责整币单位与最小单位转换;直接调用 Admin API 的集成必须自行传最小单位。
### 官方平台认证
官方认证对应 `user.verified` / `channel.verified`。申请由内置 `@verifybot` 收集、管理后台审核;提交和批准时都会重新校验目标存在、公开 username、申请者控制权、账号状态与系统账号禁入badge 写入和决定状态在同一事务提交。申请 URL 只做 public http(s) 形状校验,服务端不会抓取,避免 SSRF。
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_VERIFICATION_ENABLED` | bool / `true` | 启用官方认证;关闭时拒绝新操作,既有 badge 保留。 |
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | bool / `false` | 是否允许普通用户账号成为认证目标。 |
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | duration / `720h` | 同申请者/目标被拒后的等待期;允许 `0..8760h`。 |
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | int / `3` | 每个申请者在窗口内可新建的申请数;`0` 关闭。 |
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | duration / `24h` | 申请预算窗口limit>0 时必须为正数。 |
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | int / `30` | `@verifybot` 每个申请者的对话限流;`0` 关闭。 |
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | duration / `1m` | bot 对话限流窗口limit>0 时必须为正数。 |
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | duration / `15s` | durable 通知 outbox worker 周期,必须为正数。 |
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | int / `50` | 每轮投递通知数,必须为 `1..500`。 |
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | int / `3` | 每个申请者可保持的 active 申请数;允许 `0..50`。 |
### 第三方 bot 认证
第三方认证对应 `botVerification`,由管理员授权的 verifier bot 使用自己的 custom emoji document 与描述标记 user/channel不会授予官方 checkmark。Icon 必须是客户端可通过 `messages.getCustomEmojiDocuments` 读取的真实 document。每个 peer 只保留一个 wire-visible mark新的 verifier 替换旧 mark禁止旧 badge 在撤销或 kill switch 后意外复活。客户端自定义描述上限通过 appConfig `bot_verification_description_length_limit=70` 发布。
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_BOT_VERIFICATION_ENABLED` | bool / `true` | 启用第三方认证;关闭时拒绝新 mutation既有 mark 仍投影。 |
| `TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` | int / `10000` | 单 verifier 可标记的 peer 数;`0` 仅关闭 service capstorage 硬上限仍为 10000。 |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT` | int / `5` | 每个申请者跨 verifier 的申请预算;`0` 关闭。 |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW` | duration / `24h` | 第三方认证申请预算窗口limit>0 时必须为正数。 |
## 11. 私聊通话、群通话、TURN、SFU 与直播
| 参数 | 类型 / 代码默认值 | 说明与约束 |

291
docs/verification.md Normal file
View file

@ -0,0 +1,291 @@
# 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.