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>
314 lines
12 KiB
Go
314 lines
12 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/iamxvbaba/td/tg"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// Third-party bot verification on the protocol edge
|
|
// (core.telegram.org/api/bots/verification).
|
|
//
|
|
// Layer 228 spreads one fact -- "verifier bot B marked peer P with icon I and
|
|
// description D" -- over six unrelated constructors, and an official client only
|
|
// renders the badge when the exact bit is set:
|
|
//
|
|
// 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
|
|
//
|
|
// Every projection here goes through the generated Set* helpers rather than a raw
|
|
// field assignment, because the flags word is what the client reads: a struct field
|
|
// set without its bit encodes as an absent field and the badge silently disappears.
|
|
//
|
|
// Deps.BotVerifications is the single source. Nothing in this file recomputes the
|
|
// mark, and nothing derives it from the official verified flag (user flags.17 /
|
|
// channel flags.7) -- that is a separate operator-granted mechanism, and mixing the
|
|
// two would let a third-party verifier mint platform checkmarks.
|
|
|
|
// tgBotVerification projects domain.BotVerification onto botVerification#f93cd45c.
|
|
//
|
|
// The bool reports whether the payload is renderable at all: the icon is a custom
|
|
// emoji document id resolved through messages.getCustomEmojiDocuments, so a zero
|
|
// icon (or an unattributed mark) would encode a badge the client draws as nothing.
|
|
// Such a mark is omitted instead of shipped half-formed.
|
|
func tgBotVerification(in domain.BotVerification) (tg.BotVerification, bool) {
|
|
if in.BotID <= 0 || in.Icon <= 0 {
|
|
return tg.BotVerification{}, false
|
|
}
|
|
return tg.BotVerification{
|
|
BotID: in.BotID,
|
|
Icon: in.Icon,
|
|
Description: in.Description,
|
|
}, true
|
|
}
|
|
|
|
// tgBotVerifierSettings projects domain.BotVerifierSettings onto
|
|
// botVerifierSettings#b0cd6617.
|
|
//
|
|
// custom_description:flags.0 carries the operator default description (the text the
|
|
// verifier applies when it supplies none per peer);
|
|
// can_modify_custom_description:flags.1 is the permission that lets the verifier
|
|
// override it. A configuration that does not validate is omitted entirely rather
|
|
// than encoded with an empty company, which clients render as a blank badge sheet.
|
|
func tgBotVerifierSettings(in domain.BotVerifierSettings) (tg.BotVerifierSettings, bool) {
|
|
if err := in.Validate(); err != nil {
|
|
return tg.BotVerifierSettings{}, false
|
|
}
|
|
out := tg.BotVerifierSettings{
|
|
Icon: in.IconDocumentID,
|
|
Company: strings.TrimSpace(in.CompanyName),
|
|
}
|
|
if in.CanModifyCustomDescription {
|
|
out.SetCanModifyCustomDescription(true)
|
|
}
|
|
if desc := strings.TrimSpace(in.DefaultDescription); desc != "" {
|
|
out.SetCustomDescription(desc)
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// applyBotVerificationIconsToPeerObjects overlays user#b1b8cc83
|
|
// bot_verification_icon:flags2.14 and channel#d49f34c6
|
|
// bot_verification_icon:flags2.13 onto already-projected peer objects.
|
|
//
|
|
// It mirrors applyUsernamesToPeerObjects exactly, including why it is an overlay:
|
|
// tgUser/tgChannel run inside per-id loops (users.getUsers, dialog lists), so a
|
|
// per-object read there would be the N+1 this pass exists to avoid. Running at the
|
|
// response boundary keeps every one of the ~90 plain projection call sites
|
|
// untouched and still reaches them all.
|
|
//
|
|
// A nil service or any read error is a silent no-op: the encoded peer then stays
|
|
// byte-identical to the pre-feature shape.
|
|
func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, users []tg.UserClass, chats []tg.ChatClass) {
|
|
if r.deps.BotVerifications == nil || len(users)+len(chats) == 0 {
|
|
return
|
|
}
|
|
peers := make([]domain.Peer, 0, len(users)+len(chats))
|
|
seen := make(map[domain.Peer]struct{}, len(users)+len(chats))
|
|
addPeer := func(peer domain.Peer) {
|
|
if peer.ID == 0 {
|
|
return
|
|
}
|
|
if _, ok := seen[peer]; ok {
|
|
return
|
|
}
|
|
seen[peer] = struct{}{}
|
|
peers = append(peers, peer)
|
|
}
|
|
for _, item := range users {
|
|
if u, ok := item.(*tg.User); ok && u != nil {
|
|
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
|
|
}
|
|
}
|
|
for _, item := range chats {
|
|
if ch, ok := item.(*tg.Channel); ok && ch != nil {
|
|
addPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID})
|
|
}
|
|
}
|
|
if len(peers) == 0 {
|
|
return
|
|
}
|
|
byPeer := r.botVerificationMap(ctx, peers)
|
|
if len(byPeer) == 0 {
|
|
return
|
|
}
|
|
for _, item := range users {
|
|
u, ok := item.(*tg.User)
|
|
if !ok || u == nil {
|
|
continue
|
|
}
|
|
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
|
|
if !ok || mark.IconDocumentID <= 0 {
|
|
continue
|
|
}
|
|
u.SetBotVerificationIcon(mark.IconDocumentID)
|
|
}
|
|
for _, item := range chats {
|
|
ch, ok := item.(*tg.Channel)
|
|
if !ok || ch == nil {
|
|
continue
|
|
}
|
|
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
|
|
if !ok || mark.IconDocumentID <= 0 {
|
|
continue
|
|
}
|
|
ch.SetBotVerificationIcon(mark.IconDocumentID)
|
|
}
|
|
}
|
|
|
|
// botVerificationMap loads the marks for the given peers. One peer goes through
|
|
// PeerVerification so a single-object projection does not pay for a batch round
|
|
// trip; anything larger goes through PeerVerificationBatch, so a list response
|
|
// costs one query regardless of length. Any error yields an empty map, which every
|
|
// caller treats as "no badge".
|
|
func (r *Router) botVerificationMap(ctx context.Context, peers []domain.Peer) map[domain.Peer]domain.CustomVerification {
|
|
if r.deps.BotVerifications == nil || len(peers) == 0 {
|
|
return nil
|
|
}
|
|
if len(peers) == 1 {
|
|
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peers[0])
|
|
if err != nil || mark.IconDocumentID <= 0 {
|
|
return nil
|
|
}
|
|
return map[domain.Peer]domain.CustomVerification{peers[0]: mark}
|
|
}
|
|
byPeer, err := r.deps.BotVerifications.PeerVerificationBatch(ctx, peers)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return byPeer
|
|
}
|
|
|
|
// peerBotVerificationIcon resolves just the icon for one peer, for the update
|
|
// fan-outs. They build one peer object per recipient from the same peer-wide fact,
|
|
// so they resolve it once with this and then stamp it with the helpers below rather
|
|
// than reading inside their per-recipient builder. Zero means "no mark", which
|
|
// leaves the flag unset.
|
|
func (r *Router) peerBotVerificationIcon(ctx context.Context, peer domain.Peer) int64 {
|
|
if r.deps.BotVerifications == nil || peer.ID <= 0 {
|
|
return 0
|
|
}
|
|
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peer)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return mark.IconDocumentID
|
|
}
|
|
|
|
// applyBotVerificationIconToUsers stamps an already-resolved icon onto the matching
|
|
// user object of one push (user#b1b8cc83 bot_verification_icon:flags2.14).
|
|
func applyBotVerificationIconToUsers(users []tg.UserClass, userID, icon int64) {
|
|
if icon <= 0 || userID <= 0 {
|
|
return
|
|
}
|
|
for _, item := range users {
|
|
if u, ok := item.(*tg.User); ok && u != nil && u.ID == userID {
|
|
u.SetBotVerificationIcon(icon)
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyBotVerificationIconToChannelChats is the channel counterpart
|
|
// (channel#d49f34c6 bot_verification_icon:flags2.13).
|
|
func applyBotVerificationIconToChannelChats(chats []tg.ChatClass, channelID, icon int64) {
|
|
if icon <= 0 || channelID <= 0 {
|
|
return
|
|
}
|
|
for _, item := range chats {
|
|
if ch, ok := item.(*tg.Channel); ok && ch != nil && ch.ID == channelID {
|
|
ch.SetBotVerificationIcon(icon)
|
|
}
|
|
}
|
|
}
|
|
|
|
// peerBotVerification resolves the single-peer projection payload.
|
|
func (r *Router) peerBotVerification(ctx context.Context, peer domain.Peer) (tg.BotVerification, bool) {
|
|
if r.deps.BotVerifications == nil || peer.ID <= 0 {
|
|
return tg.BotVerification{}, false
|
|
}
|
|
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peer)
|
|
if err != nil {
|
|
// Includes domain.ErrCustomVerificationNotFound: an unmarked peer simply has
|
|
// no badge to show.
|
|
return tg.BotVerification{}, false
|
|
}
|
|
return tgBotVerification(mark.Projection())
|
|
}
|
|
|
|
// applyBotVerificationToUserFull sets userFull#6cbe645
|
|
// bot_verification:flags2.12.
|
|
//
|
|
// It runs as a post-cache overlay on both users.getFullUser paths (cache hit and
|
|
// fresh build) and clears the bit first, so a revoked mark disappears on the next
|
|
// response instead of surviving inside the per-(viewer,target) projection cache
|
|
// TTL. The payload is viewer-independent by construction: a badge granted by a
|
|
// verifier is a property of the peer, not of who is looking.
|
|
func (r *Router) applyBotVerificationToUserFull(ctx context.Context, userID int64, full *tg.UserFull) {
|
|
if r.deps.BotVerifications == nil || full == nil || userID <= 0 {
|
|
return
|
|
}
|
|
full.Flags2.Unset(12)
|
|
full.BotVerification = tg.BotVerification{}
|
|
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}); ok {
|
|
full.SetBotVerification(value)
|
|
}
|
|
}
|
|
|
|
// applyBotVerificationToChannelFull sets channelFull#a04e8d3a
|
|
// bot_verification:flags2.17, on the same post-cache overlay contract as
|
|
// applyBotVerificationToUserFull.
|
|
func (r *Router) applyBotVerificationToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
|
|
if r.deps.BotVerifications == nil || full == nil || channelID <= 0 {
|
|
return
|
|
}
|
|
full.Flags2.Unset(17)
|
|
full.BotVerification = tg.BotVerification{}
|
|
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}); ok {
|
|
full.SetBotVerification(value)
|
|
}
|
|
}
|
|
|
|
// applyBotVerificationToChatInvite sets chatInvite#5c9d3702
|
|
// bot_verification:flags.13.
|
|
//
|
|
// A non-member sees only this preview, so the badge has to be visible here for the
|
|
// same reason verified/scam/fake are (applyChatInviteModerationFlags): a mark that
|
|
// appears only after joining is exactly backwards.
|
|
func (r *Router) applyBotVerificationToChatInvite(ctx context.Context, invite *tg.ChatInvite, channelID int64) {
|
|
if r.deps.BotVerifications == nil || invite == nil || channelID <= 0 {
|
|
return
|
|
}
|
|
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}); ok {
|
|
invite.SetBotVerification(value)
|
|
}
|
|
}
|
|
|
|
// applyVerifierSettingsToBotInfo sets botInfo#4d8a0299
|
|
// verifier_settings:flags.9 -- the block a client shows inside a verifier bot's
|
|
// own profile, and what tells it the bot may verify others.
|
|
//
|
|
// The operator kill switch is honoured here: a disabled verifier keeps its row and
|
|
// the marks it already granted, but stops advertising itself as a verifier, so the
|
|
// client stops offering the verification UI.
|
|
func applyVerifierSettingsToBotInfo(info *tg.BotInfo, botUserID int64, settings domain.BotVerifierSettings) {
|
|
if info == nil || botUserID <= 0 || !settings.Enabled || settings.BotID != botUserID {
|
|
return
|
|
}
|
|
if value, ok := tgBotVerifierSettings(settings); ok {
|
|
info.SetVerifierSettings(value)
|
|
}
|
|
}
|
|
|
|
// applyVerifierSettingsToOneBotInfo is the single-bot path (userFull.bot_info).
|
|
func (r *Router) applyVerifierSettingsToOneBotInfo(ctx context.Context, info *tg.BotInfo, botUserID int64) {
|
|
if r.deps.BotVerifications == nil || info == nil || botUserID <= 0 {
|
|
return
|
|
}
|
|
settings, err := r.deps.BotVerifications.VerifierSettings(ctx, botUserID)
|
|
if err != nil {
|
|
// Includes domain.ErrVerifierNotFound: an ordinary bot is not a verifier.
|
|
return
|
|
}
|
|
applyVerifierSettingsToBotInfo(info, botUserID, settings)
|
|
}
|
|
|
|
// verifierSettingsBatch resolves verifier status for several bots at once, next to
|
|
// the profile batch resolver tgBotInfos already uses, so channelFull.bot_info costs
|
|
// one settings query for the whole bot list.
|
|
func (r *Router) verifierSettingsBatch(ctx context.Context, botUserIDs []int64) map[int64]domain.BotVerifierSettings {
|
|
if r.deps.BotVerifications == nil || len(botUserIDs) == 0 {
|
|
return nil
|
|
}
|
|
settings, err := r.deps.BotVerifications.VerifierSettingsBatch(ctx, botUserIDs)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return settings
|
|
}
|