owpengram-server/docs/verification.md
Egor Egorov fff8de783a
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>
2026-07-28 01:18:00 +08:00

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 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 NotifyChannelChangedchannelStateMutationUpdatespushChannelStateToMembersWithLinkedMonoforum, 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.