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>
17 KiB
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 issuesbotVerifierSettingsto anybody; bots.setCustomVerificationis routed and argument-checked at the RPC edge and then refused with403 BOT_VERIFIER_FORBIDDEN(internal/rpc/bots_longtail.go), because no bot on this deployment is a verifier;- a client that renders
bot_verification_iconrenders 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
@verifybot(user id1250000011, seeded by migration0152) 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.- 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. - Submission writes
verification_applications(statussubmitted) and an immutableverification_application_eventsrow. - Admin panel lists the queue and lets a reviewer claim, approve or reject.
Panel BFF routes are under
/api/verification/...; the Admin API routes areGET /v1/verification/applications,.../{id},.../counts,POST .../{id}/claim|approve|rejectandPOST /v1/verification/revoke. Every decision also goes through the shared admin command journal, so it lands inadmin_commands/admin_audit_logs. - 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.
- Decision transaction. The status transition, the audit event, the
applicant-notification outbox row and the
verifiedflag write on the peer commit in one transaction (verification.PeerVerifieris invoked with the store transaction taken from the context). "Approved" and "the peer carries the badge" can never disagree. - 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-PTSupdateUserfan-out the scam/fake flags use. The audience isModerationFlagAudience(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.updateChannelplus the refreshedchannel#d49f34c6object 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.
- drops the cached peer projections for the target
(
- Applicant notification.
@verifybotmessages the applicant from a durable outbox drained by a retrying worker, never from inside the decision transaction. Kinds areapproved,rejected,revoked. - What the client sees. An online client applies the flag from the pushed
User/Channelobject 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.verifiedis listed intelesrv_notify_user_base_read_model(triggerusers_read_model_changed), which bumpsuser_base,contact_accountand the private dialog-light models, and intelesrv_notify_user_channel_participants_read_model(triggerusers_channel_participants_read_model_changed), which bumpschannel_participants;channelsbumpschannel_baseon every row change (triggerchannels_read_model_changed) and additionally firespg_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_botseeds@verifybot(id1250000011, fixedaccess_hashdouble-written withdomain.VerifyBotAccessHash), itsbotsrow and command list, and itspeer_usernamesregistry entry. The handle is occupied from the moment the schema is current, so an ordinary user cannot claim@verifybotin the window before first use.0154_verification_applicationscreatesverification_applications(the durable audit subject, never deleted, moved throughdraft → submitted → in_review → approved|rejected|cancelledunder an optimistic-lockingversion), the append-onlyverification_application_eventshistory, andverification_notification_outboxwithUNIQUE (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
- Log in to the deployment with official Telegram Desktop.
- Open
@verifybot— it resolves by username and its own profile already shows the badge (the seed row isverified). - 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. - Send
/status; the application is listed as submitted. - In the admin panel open the verification queue, claim the application and approve it. Confirm the audit entry appeared.
- Within one notification-worker interval
@verifybotmessages the applicant with the decision. - 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
@usernamein 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.7path.)
- To check offline convergence, quit the client before approving, approve, then
start it again: the badge is present on the first read, delivered by
getDifferenceand the peer reads it triggers rather than by a live push. - 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.setCustomVerificationnever succeeds: it validates its arguments and then refuses with403 BOT_VERIFIER_FORBIDDEN. NobotVerifierSettingsare issued, anduser.bot_verification_icon/channel.bot_verification_icon/channelFull.bot_verification/chatInvite.bot_verificationare 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
@usernameis 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 noverifiedfield 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.
NotifyPeerVerifiedfilters 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/updateChannelcarry nopts. 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
usersservice and therefore its Redisuser:baserefresh; that cache is dropped cross-instance by the asynchronoususer_baseread-model notification.NotifyPeerVerifiedruns synchronously right after commit, so in the small window before the listener processes the event the pusheduserobject 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.