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

View file

@ -64,6 +64,12 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
return r.onAccountUpdateUsername(ctx, layerRequest.
Username)
})
registerRPC[*tg.AccountReorderUsernamesRequest](d, tlprofile.SemanticMethodAccountReorderUsernames, func(ctx context.Context, layerRequest *tg.AccountReorderUsernamesRequest) (any, error) {
return r.onAccountReorderUsernames(ctx, layerRequest)
})
registerRPC[*tg.AccountToggleUsernameRequest](d, tlprofile.SemanticMethodAccountToggleUsername, func(ctx context.Context, layerRequest *tg.AccountToggleUsernameRequest) (any, error) {
return r.onAccountToggleUsername(ctx, layerRequest)
})
registerRPC[*tg.AccountUpdateBirthdayRequest](d, tlprofile.SemanticMethodAccountUpdateBirthday, func(ctx context.Context, layerRequest *tg.AccountUpdateBirthdayRequest) (any, error) {
return r.onAccountUpdateBirthday(ctx, layerRequest)
})
@ -1555,6 +1561,54 @@ func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (
return r.tgSelfUser(u), nil
}
// onAccountReorderUsernames rewrites the caller's active username order
// (account.reorderUsernames). The editable slot is included when active, just as
// it is in the vector sent by official clients.
//
// Without a username registry the account owns a single editable username, which
// has no order to change: USERNAME_NOT_MODIFIED is the accurate answer and keeps
// clients from believing a reorder took effect.
func (r *Router) onAccountReorderUsernames(ctx context.Context, req *tg.AccountReorderUsernamesRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req == nil {
return false, usernameInvalidErr()
}
if len(req.Order) > domain.MaxPeerCollectibleUsernames+1 {
return false, limitInvalidErr()
}
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
if err := r.reorderRegistryUsernames(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, req.Order); err != nil {
return false, err
}
return true, nil
}
// onAccountToggleUsername activates/deactivates one of the caller's own
// collectible usernames (account.toggleUsername). Deactivating the editable slot
// through this method is rejected by the domain rules with USERNAME_INVALID:
// clearing the editable username is account.updateUsername's job.
func (r *Router) onAccountToggleUsername(ctx context.Context, req *tg.AccountToggleUsernameRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req == nil {
return false, usernameInvalidErr()
}
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
if err := r.toggleRegistryUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
// onAccountUpdateBirthday 持久化资料页生日account.updateBirthday。birthday 缺省即清除;
// 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull按隐私 PrivacyKeyBirthday 对外裁剪)。
// 写入后推 updateUser 信号给本人其它在线 session促使已加载 full profile 的客户端重拉。
@ -1869,14 +1923,24 @@ func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
if u.ID == 0 {
return
}
// updateUserName carries the vector clients persist, so it has to be the full
// registry list when one exists. One peer, one registry read; the overlay
// degrades to tgUsernames(u.Username) whenever the registry is unavailable.
self := r.tgSelfUser(u)
users := []tg.UserClass{self}
r.applyUsernamesToPeerObjects(ctx, users, nil)
usernames := tgUsernames(u.Username)
if vector, ok := self.GetUsernames(); ok && len(vector) > 0 {
usernames = vector
}
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserName{
UserID: u.ID,
FirstName: u.FirstName,
LastName: u.LastName,
Usernames: tgUsernames(u.Username),
Usernames: usernames,
}},
Users: []tg.UserClass{r.tgSelfUser(u)},
Users: users,
Date: int(r.clock.Now().Unix()),
})
}

View file

@ -0,0 +1,616 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
// This file is the load-bearing test of the third-party verification feature.
//
// An official client renders the badge off one specific bit of one specific flags
// word. A projection that sets the neighbouring bit encodes a different, valid
// field -- the response still decodes, no error is raised anywhere, and the badge
// simply never appears. So the assertions below are not "the Go field is populated"
// but "the encoded flags word differs from the unmarked encoding in exactly the bit
// layer 228 assigns":
//
// 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
//
// Each case encodes the same object twice through the real handler -- once before
// the mark exists and once after -- and XORs the two flags words. That catches an
// off-by-one bit, and also catches a projection that quietly disturbs an unrelated
// flag while adding the badge.
// tlRoundTrip serialises through the wire and decodes back, so every assertion
// reads the encoded form rather than the in-memory struct.
func tlRoundTrip(t *testing.T, in bin.Encoder, out bin.Decoder) {
t.Helper()
buf := &bin.Buffer{}
if err := in.Encode(buf); err != nil {
t.Fatalf("encode %T: %v", in, err)
}
if err := out.Decode(buf); err != nil {
t.Fatalf("decode %T: %v", out, err)
}
if buf.Len() != 0 {
t.Fatalf("decode %T left %d trailing bytes", out, buf.Len())
}
}
// assertFlagBitDelta pins that adding the mark flipped exactly one bit of the flags
// word, at the index layer 228 assigns.
func assertFlagBitDelta(t *testing.T, label string, before, after bin.Fields, wantBit int) {
t.Helper()
want := uint32(1) << uint(wantBit)
delta := uint32(before) ^ uint32(after)
if delta != want {
t.Fatalf("%s flags delta = %032b, want exactly bit %d (%032b); before %032b after %032b",
label, delta, wantBit, want, uint32(before), uint32(after))
}
if uint32(after)&want == 0 {
t.Fatalf("%s did not set bit %d: flags = %032b", label, wantBit, uint32(after))
}
}
// TestBotVerificationConstructorIDs pins the two constructor ids the feature
// serialises. A drift here silently reshapes every payload below.
func TestBotVerificationConstructorIDs(t *testing.T) {
if tg.BotVerificationTypeID != 0xf93cd45c {
t.Fatalf("botVerification constructor id = %#x, want 0xf93cd45c", tg.BotVerificationTypeID)
}
if tg.BotVerifierSettingsTypeID != 0xb0cd6617 {
t.Fatalf("botVerifierSettings constructor id = %#x, want 0xb0cd6617", tg.BotVerifierSettingsTypeID)
}
// The carriers, so a layer bump that reshuffles them is caught here too.
if tg.UserTypeID != 0xb1b8cc83 || tg.ChannelTypeID != 0xd49f34c6 {
t.Fatalf("peer constructor ids = user %#x / channel %#x", tg.UserTypeID, tg.ChannelTypeID)
}
if tg.UserFullTypeID != 0x6cbe645 || tg.ChannelFullTypeID != 0xa04e8d3a {
t.Fatalf("full constructor ids = userFull %#x / channelFull %#x", tg.UserFullTypeID, tg.ChannelFullTypeID)
}
if tg.ChatInviteTypeID != 0x5c9d3702 || tg.BotInfoTypeID != 0x4d8a0299 {
t.Fatalf("constructor ids = chatInvite %#x / botInfo %#x", tg.ChatInviteTypeID, tg.BotInfoTypeID)
}
if tg.BotsSetCustomVerificationRequestTypeID != 0x8b89dfbd {
t.Fatalf("bots.setCustomVerification id = %#x, want 0x8b89dfbd", tg.BotsSetCustomVerificationRequestTypeID)
}
}
// TestBotVerificationTLProjectionShapes pins the two payload builders: the icon is a
// custom emoji document id, so a mark without one is omitted rather than encoded as
// a badge the client draws as nothing.
func TestBotVerificationTLProjectionShapes(t *testing.T) {
value, ok := tgBotVerification(domain.BotVerification{BotID: 42, Icon: 777, Description: "Verified by Acme"})
if !ok {
t.Fatal("complete mark was rejected")
}
decoded := &tg.BotVerification{}
tlRoundTrip(t, &value, decoded)
if decoded.BotID != 42 || decoded.Icon != 777 || decoded.Description != "Verified by Acme" {
t.Fatalf("botVerification = %+v", decoded)
}
for _, bad := range []domain.BotVerification{
{BotID: 0, Icon: 777},
{BotID: 42, Icon: 0},
} {
if _, ok := tgBotVerification(bad); ok {
t.Fatalf("unrenderable mark %+v was projected", bad)
}
}
settings, ok := tgBotVerifierSettings(domain.BotVerifierSettings{
BotID: 42, IconDocumentID: 777, CompanyName: "Acme Trust",
DefaultDescription: "Verified by Acme Trust", CanModifyCustomDescription: true, Enabled: true,
})
if !ok {
t.Fatal("valid verifier settings were rejected")
}
decodedSettings := &tg.BotVerifierSettings{}
tlRoundTrip(t, &settings, decodedSettings)
if decodedSettings.Icon != 777 || decodedSettings.Company != "Acme Trust" {
t.Fatalf("botVerifierSettings = %+v", decodedSettings)
}
if !decodedSettings.GetCanModifyCustomDescription() || !decodedSettings.Flags.Has(1) {
t.Fatalf("can_modify_custom_description not on flags.1: %032b", uint32(decodedSettings.Flags))
}
if desc, ok := decodedSettings.GetCustomDescription(); !ok || desc != "Verified by Acme Trust" ||
!decodedSettings.Flags.Has(0) {
t.Fatalf("custom_description not on flags.0: %q ok=%v flags %032b", desc, ok, uint32(decodedSettings.Flags))
}
// A verifier with no default description leaves flags.0 clear.
bare, ok := tgBotVerifierSettings(domain.BotVerifierSettings{
BotID: 42, IconDocumentID: 777, CompanyName: "Acme Trust", Enabled: true,
})
if !ok {
t.Fatal("verifier settings without a default description were rejected")
}
decodedBare := &tg.BotVerifierSettings{}
tlRoundTrip(t, &bare, decodedBare)
if decodedBare.Flags.Has(0) || decodedBare.Flags.Has(1) {
t.Fatalf("bare verifier settings set optional flags: %032b", uint32(decodedBare.Flags))
}
// A configuration that does not validate is omitted entirely.
if _, ok := tgBotVerifierSettings(domain.BotVerifierSettings{BotID: 42, IconDocumentID: 777, Enabled: true}); ok {
t.Fatal("verifier settings without a company were projected")
}
}
// getUsersUser runs users.getUsers and returns the wire form of one projected user.
func getUsersUser(t *testing.T, r *Router, viewerUserID int64, target domain.User) *tg.User {
t.Helper()
out, err := r.onUsersGetUsers(WithUserID(context.Background(), viewerUserID),
[]tg.InputUserClass{&tg.InputUser{UserID: target.ID, AccessHash: target.AccessHash}})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(out) != 1 {
t.Fatalf("get users returned %d users, want 1", len(out))
}
decoded := &tg.User{}
tlRoundTrip(t, out[0].(*tg.User), decoded)
return decoded
}
// getFullUserProjection runs users.getFullUser and returns the wire forms of the
// userFull and (when present) its bot_info.
func getFullUserProjection(t *testing.T, r *Router, viewerUserID int64, target domain.User) (*tg.UserFull, *tg.BotInfo) {
t.Helper()
res, err := r.onUsersGetFullUser(WithUserID(context.Background(), viewerUserID),
&tg.InputUser{UserID: target.ID, AccessHash: target.AccessHash})
if err != nil {
t.Fatalf("get full user: %v", err)
}
full := res.FullUser
decoded := &tg.UserFull{}
tlRoundTrip(t, &full, decoded)
info, ok := decoded.GetBotInfo()
if !ok {
return decoded, nil
}
return decoded, &info
}
// TestUserAndUserFullCarryBotVerificationOnLayer228Bits is the user half: the icon
// on user#b1b8cc83 flags2.14 and the full payload on userFull#6cbe645 flags2.12.
func TestUserAndUserFullCarryBotVerificationOnLayer228Bits(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800001, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
plainUser := getUsersUser(t, f.router, f.owner.ID, f.target)
if _, ok := plainUser.GetBotVerificationIcon(); ok {
t.Fatalf("unmarked user carries an icon: %+v", plainUser)
}
plainFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
if _, ok := plainFull.GetBotVerification(); ok {
t.Fatalf("unmarked userFull carries a mark: %+v", plainFull)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Official reseller")); err != nil || !ok {
t.Fatalf("grant = %v,%v, want true,nil", ok, err)
}
markedUser := getUsersUser(t, f.router, f.owner.ID, f.target)
icon, ok := markedUser.GetBotVerificationIcon()
if !ok || icon != 8800001 {
t.Fatalf("user bot_verification_icon = %d, ok=%v, want 8800001", icon, ok)
}
assertFlagBitDelta(t, "user", plainUser.Flags2, markedUser.Flags2, 14)
if uint32(plainUser.Flags) != uint32(markedUser.Flags) {
t.Fatalf("user flags word changed: before %032b after %032b", uint32(plainUser.Flags), uint32(markedUser.Flags))
}
// The operator-granted checkmark is a different mechanism and must stay clear.
if markedUser.Verified || markedUser.Flags.Has(17) {
t.Fatalf("third-party mark leaked into official verified:flags.17: %+v", markedUser)
}
markedFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
mark, ok := markedFull.GetBotVerification()
if !ok {
t.Fatalf("userFull bot_verification unset: %+v", markedFull)
}
if mark.BotID != f.bot.ID || mark.Icon != 8800001 || mark.Description != "Official reseller" {
t.Fatalf("userFull bot_verification = %+v, want verifier %d icon 8800001", mark, f.bot.ID)
}
assertFlagBitDelta(t, "userFull", plainFull.Flags2, markedFull.Flags2, 12)
if uint32(plainFull.Flags) != uint32(markedFull.Flags) {
t.Fatalf("userFull flags word changed: before %032b after %032b", uint32(plainFull.Flags), uint32(markedFull.Flags))
}
// Revoking clears the bit again on both surfaces: the payload is an overlay, so
// it must not survive inside the userFull projection cache.
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, "")); err != nil || !ok {
t.Fatalf("revoke = %v,%v, want true,nil", ok, err)
}
if revokedUser := getUsersUser(t, f.router, f.owner.ID, f.target); revokedUser.Flags2.Has(14) {
t.Fatalf("revoked user still carries flags2.14: %032b", uint32(revokedUser.Flags2))
}
revokedFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
if revokedFull.Flags2.Has(12) {
t.Fatalf("revoked userFull still carries flags2.12: %032b", uint32(revokedFull.Flags2))
}
}
// TestBotInfoCarriesVerifierSettingsOnFlags9 pins botInfo#4d8a0299
// verifier_settings:flags.9 inside the verifier bot's own userFull, and the operator
// kill switch: a disabled verifier stops advertising itself.
func TestBotInfoCarriesVerifierSettingsOnFlags9(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
_, plainInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if plainInfo == nil {
t.Fatal("bot userFull carries no bot_info at all")
}
if _, ok := plainInfo.GetVerifierSettings(); ok {
t.Fatalf("ordinary bot advertises verifier settings: %+v", plainInfo)
}
f.enableVerifier(f.bot.ID, 8800002, true)
f.router.invalidateRPCProjectionForUser(f.bot.ID)
_, markedInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if markedInfo == nil {
t.Fatal("verifier bot userFull carries no bot_info")
}
settings, ok := markedInfo.GetVerifierSettings()
if !ok {
t.Fatalf("verifier bot bot_info has no verifier_settings: %+v", markedInfo)
}
if settings.Icon != 8800002 || settings.Company != "Acme Trust" || !settings.GetCanModifyCustomDescription() {
t.Fatalf("verifier_settings = %+v", settings)
}
assertFlagBitDelta(t, "botInfo", plainInfo.Flags, markedInfo.Flags, 9)
// Operator kill switch: the row stays, the advertisement stops.
disabled := f.verify.settings[f.bot.ID]
disabled.Enabled = false
f.verify.settings[f.bot.ID] = disabled
f.router.invalidateRPCProjectionForUser(f.bot.ID)
_, offInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if offInfo == nil || offInfo.Flags.Has(9) {
t.Fatalf("disabled verifier still advertises verifier_settings: %+v", offInfo)
}
}
// botVerificationGroup creates a megagroup owned by the fixture owner and returns its
// projected channel object.
func (f botVerificationFixture) botVerificationGroup(t *testing.T, title string) *tg.Channel {
t.Helper()
created, err := f.router.onMessagesCreateChat(WithUserID(context.Background(), f.owner.ID),
&tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{inputUser(f.stranger)},
Title: title,
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
return created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
}
func getChannelsChannel(t *testing.T, r *Router, viewerUserID int64, channel *tg.Channel) *tg.Channel {
t.Helper()
res, err := r.onChannelsGetChannels(WithUserID(context.Background(), viewerUserID),
[]tg.InputChannelClass{&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}})
if err != nil {
t.Fatalf("get channels: %v", err)
}
chats := res.(*tg.MessagesChats).Chats
if len(chats) != 1 {
t.Fatalf("get channels returned %d chats, want 1", len(chats))
}
decoded := &tg.Channel{}
tlRoundTrip(t, chats[0].(*tg.Channel), decoded)
return decoded
}
func getFullChannelProjection(t *testing.T, r *Router, viewerUserID int64, channel *tg.Channel) *tg.ChannelFull {
t.Helper()
res, err := r.onChannelsGetFullChannel(WithUserID(context.Background(), viewerUserID),
&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel: %v", err)
}
decoded := &tg.ChannelFull{}
tlRoundTrip(t, res.FullChat.(*tg.ChannelFull), decoded)
return decoded
}
// TestChannelAndChannelFullCarryBotVerificationOnLayer228Bits is the channel half:
// channel#d49f34c6 flags2.13 and channelFull#a04e8d3a flags2.17. Note the two bits
// differ from the user ones, which is exactly the mistake this test exists to catch.
func TestChannelAndChannelFullCarryBotVerificationOnLayer228Bits(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800003, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
group := f.botVerificationGroup(t, "Verified Group")
plainChannel := getChannelsChannel(t, f.router, f.owner.ID, group)
if _, ok := plainChannel.GetBotVerificationIcon(); ok {
t.Fatalf("unmarked channel carries an icon: %+v", plainChannel)
}
plainFull := getFullChannelProjection(t, f.router, f.owner.ID, group)
if _, ok := plainFull.GetBotVerification(); ok {
t.Fatalf("unmarked channelFull carries a mark: %+v", plainFull)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx, setCustomVerificationRequest(
&tg.InputPeerChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
inputUser(f.bot), true, "Community partner")); err != nil || !ok {
t.Fatalf("grant on channel = %v,%v, want true,nil", ok, err)
}
markedChannel := getChannelsChannel(t, f.router, f.owner.ID, group)
icon, ok := markedChannel.GetBotVerificationIcon()
if !ok || icon != 8800003 {
t.Fatalf("channel bot_verification_icon = %d, ok=%v, want 8800003", icon, ok)
}
assertFlagBitDelta(t, "channel", plainChannel.Flags2, markedChannel.Flags2, 13)
if uint32(plainChannel.Flags) != uint32(markedChannel.Flags) {
t.Fatalf("channel flags word changed: before %032b after %032b",
uint32(plainChannel.Flags), uint32(markedChannel.Flags))
}
if markedChannel.Verified || markedChannel.Flags.Has(7) {
t.Fatalf("third-party mark leaked into official verified:flags.7: %+v", markedChannel)
}
markedFull := getFullChannelProjection(t, f.router, f.owner.ID, group)
mark, ok := markedFull.GetBotVerification()
if !ok {
t.Fatalf("channelFull bot_verification unset: %+v", markedFull)
}
if mark.BotID != f.bot.ID || mark.Icon != 8800003 || mark.Description != "Community partner" {
t.Fatalf("channelFull bot_verification = %+v, want verifier %d icon 8800003", mark, f.bot.ID)
}
assertFlagBitDelta(t, "channelFull", plainFull.Flags2, markedFull.Flags2, 17)
if uint32(plainFull.Flags) != uint32(markedFull.Flags) {
t.Fatalf("channelFull flags word changed: before %032b after %032b",
uint32(plainFull.Flags), uint32(markedFull.Flags))
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx, setCustomVerificationRequest(
&tg.InputPeerChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
inputUser(f.bot), false, "")); err != nil || !ok {
t.Fatalf("revoke on channel = %v,%v, want true,nil", ok, err)
}
if revoked := getChannelsChannel(t, f.router, f.owner.ID, group); revoked.Flags2.Has(13) {
t.Fatalf("revoked channel still carries flags2.13: %032b", uint32(revoked.Flags2))
}
if revokedFull := getFullChannelProjection(t, f.router, f.owner.ID, group); revokedFull.Flags2.Has(17) {
t.Fatalf("revoked channelFull still carries flags2.17: %032b", uint32(revokedFull.Flags2))
}
}
// TestChannelFullBotInfoCarriesVerifierSettingsInOneBatch covers the batched botInfo
// path (channelFull.bot_info): the verifier settings for the whole bot list must cost
// one query, not one per bot.
func TestChannelFullBotInfoCarriesVerifierSettingsInOneBatch(t *testing.T) {
ctx := context.Background()
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800004, true)
group := f.botVerificationGroup(t, "Bot Group")
if _, err := f.bots.SetJoinGroups(ctx, f.bot.ID, true); err != nil {
t.Fatalf("enable join groups: %v", err)
}
if _, err := f.router.onChannelsInviteToChannel(WithUserID(ctx, f.owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
Users: []tg.InputUserClass{inputUser(f.bot)},
}); err != nil {
t.Fatalf("invite verifier bot: %v", err)
}
f.verify.settingsBatchCalls = 0
f.verify.settingsCalls = 0
full := getFullChannelProjection(t, f.router, f.owner.ID, group)
if len(full.BotInfo) != 1 || full.BotInfo[0].UserID != f.bot.ID {
t.Fatalf("channelFull bot_info = %+v, want the verifier bot", full.BotInfo)
}
settings, ok := full.BotInfo[0].GetVerifierSettings()
if !ok || !full.BotInfo[0].Flags.Has(9) {
t.Fatalf("channelFull bot_info verifier_settings unset: %+v", full.BotInfo[0])
}
if settings.Icon != 8800004 || settings.Company != "Acme Trust" {
t.Fatalf("channelFull verifier_settings = %+v", settings)
}
if f.verify.settingsBatchCalls != 1 || f.verify.settingsCalls != 0 {
t.Fatalf("verifier settings reads = batch %d / single %d, want batch 1 / single 0",
f.verify.settingsBatchCalls, f.verify.settingsCalls)
}
}
// TestChatInviteCarriesBotVerificationOnFlags13 pins chatInvite#5c9d3702
// bot_verification:flags.13 on the preview a non-member sees.
func TestChatInviteCarriesBotVerificationOnFlags13(t *testing.T) {
const channelID = int64(4242)
channel := domain.Channel{
ID: channelID, AccessHash: 42, Title: "Partner", Username: "partner",
Broadcast: true, ParticipantsCount: 11,
}
verify := newFakeBotVerifications()
r := New(Config{}, Deps{
Channels: &inviteBadgeChannels{result: domain.CheckChannelInviteResult{
Channel: channel,
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: channelID},
}},
BotVerifications: verify,
}, zap.NewNop(), clock.System)
ctx := WithUserID(context.Background(), 1001)
preview := func() *tg.ChatInvite {
t.Helper()
res, err := r.onMessagesCheckChatInvite(ctx, "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
decoded := &tg.ChatInvite{}
tlRoundTrip(t, res.(*tg.ChatInvite), decoded)
return decoded
}
plain := preview()
if _, ok := plain.GetBotVerification(); ok {
t.Fatalf("unmarked invite carries a mark: %+v", plain)
}
verify.marks[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = domain.CustomVerification{
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
IconDocumentID: 8800005, Description: "Verified by Acme Trust",
}
marked := preview()
mark, ok := marked.GetBotVerification()
if !ok {
t.Fatalf("invite bot_verification unset: %+v", marked)
}
if mark.BotID != 777000123 || mark.Icon != 8800005 || mark.Description != "Verified by Acme Trust" {
t.Fatalf("invite bot_verification = %+v", mark)
}
assertFlagBitDelta(t, "chatInvite", plain.Flags, marked.Flags, 13)
// The official badge and the moderation warnings are untouched.
if marked.GetVerified() || marked.GetScam() || marked.GetFake() {
t.Fatalf("third-party mark leaked into the moderation flags: %+v", marked)
}
if !marked.Channel || !marked.Broadcast || !marked.Public ||
marked.Title != "Partner" || marked.ParticipantsCount != 11 {
t.Fatalf("invite lost unrelated fields: %+v", marked)
}
}
// TestBotVerificationDegradesWithoutService is the whole-feature degradation test:
// with no verification service wired, not one of the six flags may be set, and every
// response must stay exactly what it was before the feature existed.
func TestBotVerificationDegradesWithoutService(t *testing.T) {
ctx := context.Background()
f := newBotVerificationFixture(t, nil)
group := f.botVerificationGroup(t, "Plain Group")
if _, err := f.bots.SetJoinGroups(ctx, f.bot.ID, true); err != nil {
t.Fatalf("enable join groups: %v", err)
}
if _, err := f.router.onChannelsInviteToChannel(WithUserID(ctx, f.owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
Users: []tg.InputUserClass{inputUser(f.bot)},
}); err != nil {
t.Fatalf("invite bot: %v", err)
}
if user := getUsersUser(t, f.router, f.owner.ID, f.target); user.Flags2.Has(14) {
t.Fatalf("user set flags2.14 without a service: %032b", uint32(user.Flags2))
}
userFull, botInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if userFull.Flags2.Has(12) {
t.Fatalf("userFull set flags2.12 without a service: %032b", uint32(userFull.Flags2))
}
if botInfo == nil {
t.Fatal("bot userFull carries no bot_info")
}
if botInfo.Flags.Has(9) {
t.Fatalf("botInfo set flags.9 without a service: %032b", uint32(botInfo.Flags))
}
if channel := getChannelsChannel(t, f.router, f.owner.ID, group); channel.Flags2.Has(13) {
t.Fatalf("channel set flags2.13 without a service: %032b", uint32(channel.Flags2))
}
full := getFullChannelProjection(t, f.router, f.owner.ID, group)
if full.Flags2.Has(17) {
t.Fatalf("channelFull set flags2.17 without a service: %032b", uint32(full.Flags2))
}
if len(full.BotInfo) != 1 || full.BotInfo[0].Flags.Has(9) {
t.Fatalf("channelFull bot_info set flags.9 without a service: %+v", full.BotInfo)
}
invite := New(Config{}, Deps{Channels: &inviteBadgeChannels{result: domain.CheckChannelInviteResult{
Channel: domain.Channel{ID: 4343, AccessHash: 43, Title: "Plain", Broadcast: true},
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: 4343},
}}}, zaptest.NewLogger(t), clock.System)
res, err := invite.onMessagesCheckChatInvite(WithUserID(ctx, 1001), "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
decodedInvite := &tg.ChatInvite{}
tlRoundTrip(t, res.(*tg.ChatInvite), decodedInvite)
if decodedInvite.Flags.Has(13) {
t.Fatalf("chatInvite set flags.13 without a service: %032b", uint32(decodedInvite.Flags))
}
}
// TestBotVerificationDegradesWhenServiceFails is the other half of the degradation
// contract: a failing read model must be indistinguishable from an unmarked peer, so
// a storage blip cannot turn a peer response into an error.
func TestBotVerificationDegradesWhenServiceFails(t *testing.T) {
verify := newFakeBotVerifications()
verify.err = context.DeadlineExceeded
f := newBotVerificationFixture(t, verify)
if user := getUsersUser(t, f.router, f.owner.ID, f.target); user.Flags2.Has(14) {
t.Fatalf("failing service still set flags2.14: %032b", uint32(user.Flags2))
}
full, botInfo := getFullUserProjection(t, f.router, f.owner.ID, f.bot)
if full.Flags2.Has(12) {
t.Fatalf("failing service still set flags2.12: %032b", uint32(full.Flags2))
}
if botInfo == nil || botInfo.Flags.Has(9) {
t.Fatalf("failing service still set botInfo flags.9: %+v", botInfo)
}
}
// TestUsersGetUsersResolvesBotVerificationInOneBatch pins the absence of an N+1: the
// icon overlay runs once per response over the whole user set, never once per user.
func TestUsersGetUsersResolvesBotVerificationInOneBatch(t *testing.T) {
verify := newFakeBotVerifications()
f := newBotVerificationFixture(t, verify)
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}] = domain.CustomVerification{
VerifierBotID: f.bot.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID},
IconDocumentID: 8800006, Description: "Verified by Acme Trust",
}
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.stranger.ID}] = domain.CustomVerification{
VerifierBotID: f.bot.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.stranger.ID},
IconDocumentID: 8800007, Description: "Verified by Acme Trust",
}
verify.peerCalls = 0
verify.batchCalls = 0
out, err := f.router.onUsersGetUsers(WithUserID(context.Background(), f.owner.ID), []tg.InputUserClass{
&tg.InputUserSelf{},
&tg.InputUser{UserID: f.target.ID, AccessHash: f.target.AccessHash},
&tg.InputUser{UserID: f.stranger.ID, AccessHash: f.stranger.AccessHash},
})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(out) != 3 {
t.Fatalf("users = %d, want 3", len(out))
}
icons := map[int64]int64{}
for _, item := range out {
decoded := &tg.User{}
tlRoundTrip(t, item.(*tg.User), decoded)
if icon, ok := decoded.GetBotVerificationIcon(); ok {
if !decoded.Flags2.Has(14) {
t.Fatalf("icon set without flags2.14 on user %d: %032b", decoded.ID, uint32(decoded.Flags2))
}
icons[decoded.ID] = icon
}
}
if icons[f.target.ID] != 8800006 || icons[f.stranger.ID] != 8800007 {
t.Fatalf("projected icons = %v", icons)
}
if _, marked := icons[f.owner.ID]; marked {
t.Fatalf("unmarked self got an icon: %v", icons)
}
// Three users, one batch read: no N+1.
if verify.batchCalls != 1 || verify.peerCalls != 0 {
t.Fatalf("verification reads = batch %d / peer %d, want batch 1 / peer 0",
verify.batchCalls, verify.peerCalls)
}
}

View file

@ -0,0 +1,107 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// NotifyPeerBotVerification is the protocol-edge hook invoked after a third-party
// verification mark has already committed -- from bots.setCustomVerification, or
// from an operator action on the same rows. It makes the new
// user#b1b8cc83 bot_verification_icon:flags2.14 /
// channel#d49f34c6 bot_verification_icon:flags2.13 (and the matching
// userFull/channelFull payload) observable without waiting for a cache TTL:
//
// - the cached peer projections for the target are dropped, so the next
// users.getFullUser / channels.getFullChannel rebuilds from the committed row;
// - online clients that already know the peer are pushed the ordinary, non-PTS
// refresh update (updateUser / updateChannel) together with the re-projected
// peer object, which is what makes the badge appear live in an official client.
//
// It is deliberately the same mechanism as NotifyPeerVerified, which is the same
// mechanism the scam/fake moderation flags use: the third-party mark is one more
// fact on the peer's base record, and inventing a verification-specific update
// would only add a second path that can drift. Nothing new is added to TL.
//
// The official verified flag is untouched here. That badge is granted by the
// operator alone (see verification_notify.go) and the two mechanisms never read or
// write each other's state.
//
// Offline sessions are not pushed to and do not need to be: the peer's base read
// model version is bumped by the users/channels triggers, so
// updates.getDifference and any later getUsers / getFullUser answer already carries
// the new mark.
//
// A push failure never invalidates the committed change, so callers log and swallow
// the returned error; this method therefore reports problems instead of panicking,
// and is safe on a nil receiver.
func (r *Router) NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer bot verification: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserBotVerification(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelBotVerification(ctx, peer.ID)
default:
return fmt.Errorf("notify peer bot verification: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
// notifyUserBotVerification covers ordinary accounts and bots alike: a marked bot is
// a user#b1b8cc83 with bot_verification_icon:flags2.14, so it takes the same
// audience-wide updateUser fan-out the moderation flags use (the peer itself plus
// every online account that already sees it).
func (r *Router) notifyUserBotVerification(ctx context.Context, userID int64) error {
// Invalidate first and unconditionally: a committed mark whose projection still
// says "unmarked" would keep serving the stale badge state even if the push below
// cannot run.
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer bot verification: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer bot verification: user %d not found", userID)
}
// NotifyUserModerationFlagsChanged re-projects the peer per recipient, so the
// snapshot handed in only carries identity; the pushed tg.User is always built
// from a fresh read and therefore picks the icon up from the batch overlay.
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
// notifyChannelBotVerification reuses the channel state-mutation path, which
// invalidates the channel projections (plus a linked monoforum's) and pushes
// updateChannel with the refreshed chat object to the channel's members.
func (r *Router) notifyChannelBotVerification(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
// The bot list cached for channelFull carries botInfo.verifier_settings, so a
// verifier's own status change has to drop it too; the channel projection cache
// is cleared by the same call.
r.invalidateChannelFullBotInfoCacheForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer bot verification: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer bot verification: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer bot verification: channel %d not found", channelID)
}
// Same hook the admin panel uses for any other channel base fact.
return r.NotifyChannelChanged(ctx, channel)
}

View file

@ -0,0 +1,335 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// TestNotifyPeerBotVerificationUserInvalidatesAndPushesUpdateUser is the bot/user
// half: a newly marked peer must reach every online account that already sees it,
// through the same non-PTS updateUser shape the scam/fake flags use, and the pushed
// tg.User must already carry bot_verification_icon:flags2.14.
func TestNotifyPeerBotVerificationUserInvalidatesAndPushesUpdateUser(t *testing.T) {
const (
shopID = int64(2200000022)
onlineViewerID = int64(1001)
offlineViewerID = int64(3003)
icon = int64(9900001)
)
users := &verifiedNotifyUsers{
user: domain.User{ID: shopID, FirstName: "Shop", AccessHash: 22},
found: true,
audience: []int64{shopID, onlineViewerID, offlineViewerID},
}
verify := newFakeBotVerifications()
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: shopID}] = domain.CustomVerification{
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: shopID},
IconDocumentID: icon, Description: "Verified by Acme Trust",
}
sessions := &captureSessions{onlineUserIDs: []int64{shopID, onlineViewerID}}
r := New(Config{}, Deps{Users: users, Sessions: sessions, BotVerifications: verify}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, onlineViewerID, shopID)
seedUserFullProjection(t, r, shopID, shopID)
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: shopID,
}); err != nil {
t.Fatalf("notify peer bot verification: %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(onlineViewerID, shopID); ok {
t.Fatal("viewer userFull projection survived the mark change")
}
if _, ok := r.userFullProjectionCache.Lookup(shopID, shopID); ok {
t.Fatal("target userFull projection survived the mark change")
}
if users.adminCalls != 1 {
t.Fatalf("authoritative account reads = %d, want 1", users.adminCalls)
}
// One read for the whole audience: the mark is a peer-wide fact, so the
// per-recipient push builder must not query it again for every recipient.
if verify.peerCalls != 1 || verify.batchCalls != 0 {
t.Fatalf("verification reads = peer %d / batch %d, want peer 1 / batch 0",
verify.peerCalls, verify.batchCalls)
}
// Offline audience members are skipped; they converge through the bumped peer
// read model on their next getDifference / getUsers.
pushed := sessions.pushedUserIDs()
if len(pushed) != 2 || pushed[0] != shopID || pushed[1] != onlineViewerID {
t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateUser)
if !ok || refresh.UserID != shopID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Users) != 1 {
t.Fatalf("users = %+v", updates.Users)
}
pushedUser := &tg.User{}
tlRoundTrip(t, updates.Users[0].(*tg.User), pushedUser)
got, ok := pushedUser.GetBotVerificationIcon()
if !ok || got != icon {
t.Fatalf("pushed user bot_verification_icon = %d ok=%v, want %d on flags2.14", got, ok, icon)
}
if !pushedUser.Flags2.Has(14) {
t.Fatalf("pushed user flags2 = %032b, want bit 14", uint32(pushedUser.Flags2))
}
// The official checkmark is a separate mechanism and must not be implied.
if pushedUser.Verified || pushedUser.Scam || pushedUser.Fake {
t.Fatalf("mark push leaked moderation flags: %+v", pushedUser)
}
}
// TestNotifyPeerBotVerificationChannelInvalidatesAndPushesUpdateChannel is the
// channel half: the mark rides the existing channel state-mutation fan-out, and the
// pushed tg.Channel carries bot_verification_icon:flags2.13.
func TestNotifyPeerBotVerificationChannelInvalidatesAndPushesUpdateChannel(t *testing.T) {
const (
channelID = int64(4404)
ownerID = int64(3003)
memberID = int64(3004)
icon = int64(9900002)
)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
Title: "Partner", Username: "partner", Broadcast: true,
}}
verify := newFakeBotVerifications()
verify.marks[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = domain.CustomVerification{
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
IconDocumentID: icon, Description: "Verified by Acme Trust",
}
sessions := &captureSessions{
onlineUserIDs: []int64{ownerID, memberID},
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
}
r := New(Config{}, Deps{Channels: channels, Sessions: sessions, BotVerifications: verify}, zap.NewNop(), clock.System)
seedChannelFullProjection(t, r, ownerID, channelID)
seedChannelFullProjection(t, r, memberID, channelID)
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify peer bot verification: %v", err)
}
if _, ok := r.channelFullProjectionCache.Lookup(ownerID, channelID); ok {
t.Fatal("owner channelFull projection survived the mark change")
}
if _, ok := r.channelFullProjectionCache.Lookup(memberID, channelID); ok {
t.Fatal("member channelFull projection survived the mark change")
}
if channels.calls != 1 {
t.Fatalf("base channel row reads = %d, want 1", channels.calls)
}
// Same contract on the channel fan-out: one read for every recipient plus the
// returned updates.
if verify.peerCalls != 1 || verify.batchCalls != 0 {
t.Fatalf("verification reads = peer %d / batch %d, want peer 1 / batch 0",
verify.peerCalls, verify.batchCalls)
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 2 {
t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
if !ok || refresh.ChannelID != channelID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Chats) != 1 {
t.Fatalf("chats = %+v", updates.Chats)
}
pushedChannel := &tg.Channel{}
tlRoundTrip(t, updates.Chats[0].(*tg.Channel), pushedChannel)
got, ok := pushedChannel.GetBotVerificationIcon()
if !ok || got != icon {
t.Fatalf("pushed channel bot_verification_icon = %d ok=%v, want %d on flags2.13", got, ok, icon)
}
if !pushedChannel.Flags2.Has(13) {
t.Fatalf("pushed channel flags2 = %032b, want bit 13", uint32(pushedChannel.Flags2))
}
if pushedChannel.Verified || pushedChannel.Scam || pushedChannel.Fake {
t.Fatalf("mark push leaked moderation flags: %+v", pushedChannel)
}
}
// TestNotifyPeerBotVerificationDropsChannelBotInfoCache guards the one cache the
// user path does not have: channelFull.bot_info bakes botInfo.verifier_settings, so a
// verifier's own status change has to drop it or the profile keeps advertising the
// old state for the cache TTL.
func TestNotifyPeerBotVerificationDropsChannelBotInfoCache(t *testing.T) {
const channelID = int64(4405)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 45, CreatorUserID: 3003, Title: "Partner", Broadcast: true,
}}
r := New(Config{}, Deps{Channels: channels, Sessions: &captureSessions{}}, zap.NewNop(), clock.System)
epoch := r.channelFullBotCache.LoadEpoch()
r.channelFullBotCache.StoreIfEpoch(3003, channelID, channelFullBotInfoResult{
userIDs: []int64{777000123},
botInfos: []tg.BotInfo{{}},
}, epoch)
if _, ok := r.channelFullBotCache.Lookup(3003, channelID); !ok {
t.Fatal("seed channelFull bot info cache")
}
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify peer bot verification: %v", err)
}
if _, ok := r.channelFullBotCache.Lookup(3003, channelID); ok {
t.Fatal("channelFull bot info cache survived the mark change")
}
}
// TestNotifyPeerBotVerificationNilRouterIsSafe pins the nil-receiver contract: the
// hook runs after the change already committed, so it may never panic.
func TestNotifyPeerBotVerificationNilRouterIsSafe(t *testing.T) {
var r *Router
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 1001,
}); err != nil {
t.Fatalf("nil router notify = %v", err)
}
}
// TestNotifyPeerBotVerificationWithoutServicesStillInvalidates covers degraded
// wiring: with no user/channel service the hook cannot push, but the stale
// projection must still be dropped and no error reported.
func TestNotifyPeerBotVerificationWithoutServicesStillInvalidates(t *testing.T) {
r := New(Config{}, Deps{}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, 1001, 2002)
seedChannelFullProjection(t, r, 1001, 4004)
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 2002,
}); err != nil {
t.Fatalf("notify user without users service = %v", err)
}
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: 4004,
}); err != nil {
t.Fatalf("notify channel without channels service = %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(1001, 2002); ok {
t.Fatal("userFull projection survived without a users service")
}
if _, ok := r.channelFullProjectionCache.Lookup(1001, 4004); ok {
t.Fatal("channelFull projection survived without a channels service")
}
}
// TestNotifyPeerBotVerificationRejectsUnknownPeers pins the "explain, never panic"
// contract for every peer the hook cannot act on.
func TestNotifyPeerBotVerificationRejectsUnknownPeers(t *testing.T) {
users := &verifiedNotifyUsers{}
channels := &verifiedNotifyChannels{}
sessions := &captureSessions{}
r := New(Config{}, Deps{Users: users, Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
ctx := context.Background()
for _, tc := range []struct {
name string
peer domain.Peer
want string
}{
{"zero id", domain.Peer{Type: domain.PeerTypeUser}, "invalid peer id"},
{"negative id", domain.Peer{Type: domain.PeerTypeChannel, ID: -1}, "invalid peer id"},
{"community peer", domain.Peer{Type: domain.PeerTypeCommunity, ID: 5005}, "unsupported peer type"},
{"empty type", domain.Peer{ID: 5005}, "unsupported peer type"},
{"missing user", domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, "user 2002 not found"},
{"missing channel", domain.Peer{Type: domain.PeerTypeChannel, ID: 4004}, "channel 4004 not found"},
} {
err := r.NotifyPeerBotVerification(ctx, tc.peer)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: err = %v, want mention of %q", tc.name, err, tc.want)
}
if err != nil && !strings.Contains(err.Error(), "notify peer bot verification") {
t.Fatalf("%s: err = %v, want the bot-verification hook named", tc.name, err)
}
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 0 {
t.Fatalf("unresolved peers pushed updates to %v", pushed)
}
}
// TestNotifyPeerBotVerificationReportsLookupFailures keeps a directory error distinct
// from "peer not found", and keeps a channels adapter without the base-row reader
// from failing silently.
func TestNotifyPeerBotVerificationReportsLookupFailures(t *testing.T) {
ctx := context.Background()
loadErr := errors.New("boom")
failing := New(Config{}, Deps{
Channels: &verifiedNotifyChannels{err: loadErr},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
if err := failing.NotifyPeerBotVerification(ctx, domain.Peer{
Type: domain.PeerTypeChannel, ID: 4004,
}); !errors.Is(err, loadErr) {
t.Fatalf("channel load error = %v", err)
}
unwired := New(Config{}, Deps{
Channels: channelsWithoutDirectory{},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
err := unwired.NotifyPeerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
if err == nil || !strings.Contains(err.Error(), "GetChannelByID") {
t.Fatalf("missing channel directory error = %v", err)
}
}
// TestSetCustomVerificationNotifiesAudience closes the loop: a successful
// bots.setCustomVerification must itself drop the projections and push the refresh,
// so the badge appears in a running client without a second RPC.
func TestSetCustomVerificationNotifiesAudience(t *testing.T) {
fake := newFakeBotVerifications()
f := newBotVerificationFixture(t, fake)
// Production wiring: the service holds the router as its PeerNotifier, so the
// push happens once, in the service, for every driver of a mark change.
fake.notifier = f.router
f.enableVerifier(f.bot.ID, 9900003, true)
sessions := &captureSessions{onlineUserIDs: []int64{f.owner.ID, f.target.ID}}
f.router.deps.Sessions = sessions
seedUserFullProjection(t, f.router, f.owner.ID, f.target.ID)
ok, err := f.router.onBotsSetCustomVerification(WithUserID(context.Background(), f.owner.ID),
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("grant = %v,%v, want true,nil", ok, err)
}
if _, cached := f.router.userFullProjectionCache.Lookup(f.owner.ID, f.target.ID); cached {
t.Fatal("userFull projection survived the grant")
}
if pushed := sessions.pushedUserIDs(); len(pushed) == 0 {
t.Fatal("grant pushed no refresh update")
}
updates, isUpdates := sessions.lastUserPush().(*tg.Updates)
if !isUpdates || len(updates.Users) == 0 {
t.Fatalf("push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
pushedUser := &tg.User{}
tlRoundTrip(t, updates.Users[0].(*tg.User), pushedUser)
if icon, set := pushedUser.GetBotVerificationIcon(); !set || icon != 9900003 {
t.Fatalf("pushed user icon = %d set=%v, want 9900003 on flags2.14", icon, set)
}
}

View file

@ -0,0 +1,314 @@
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
}

View file

@ -0,0 +1,537 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
botsapp "telesrv/internal/app/bots"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeBotVerifications is an in-memory BotVerificationService. It enforces the same
// domain rules the real service must (verifier status, the operator kill switch,
// domain.BotVerifierSettings.DescriptionFor, the per-verifier bound and
// idempotent mutations), so the RPC tests exercise the real error mapping
// instead of hand-rolled sentinels.
type fakeBotVerifications struct {
marks map[domain.Peer]domain.CustomVerification
settings map[int64]domain.BotVerifierSettings
// limit, when positive, bounds how many peers one verifier may mark.
limit int
// err, when set, fails every read. Used for the degradation tests.
err error
// Call counters let the tests assert the batch fan-out (no N+1).
peerCalls int
batchCalls int
settingsCalls int
settingsBatchCalls int
setCalls int
// notifier mirrors production wiring: the application service owns the badge
// push for all three drivers (this RPC, the bot dialog, the admin panel), so the
// fake pushes here exactly where the real service does.
notifier interface {
NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error
}
}
func newFakeBotVerifications() *fakeBotVerifications {
return &fakeBotVerifications{
marks: make(map[domain.Peer]domain.CustomVerification),
settings: make(map[int64]domain.BotVerifierSettings),
}
}
func (f *fakeBotVerifications) PeerVerification(_ context.Context, peer domain.Peer) (domain.CustomVerification, error) {
f.peerCalls++
if f.err != nil {
return domain.CustomVerification{}, f.err
}
mark, ok := f.marks[peer]
if !ok {
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
}
return mark, nil
}
func (f *fakeBotVerifications) PeerVerificationBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
f.batchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[domain.Peer]domain.CustomVerification, len(peers))
for _, peer := range peers {
if mark, ok := f.marks[peer]; ok {
out[peer] = mark
}
}
return out, nil
}
func (f *fakeBotVerifications) VerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
f.settingsCalls++
if f.err != nil {
return domain.BotVerifierSettings{}, f.err
}
settings, ok := f.settings[botID]
if !ok {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return settings, nil
}
func (f *fakeBotVerifications) VerifierSettingsBatch(_ context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) {
f.settingsBatchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[int64]domain.BotVerifierSettings, len(botIDs))
for _, id := range botIDs {
if settings, ok := f.settings[id]; ok {
out[id] = settings
}
}
return out, nil
}
func (f *fakeBotVerifications) SetCustomVerification(_ context.Context, req domain.SetCustomVerificationRequest) (bool, error) {
f.setCalls++
if f.err != nil {
return false, f.err
}
if err := req.Validate(); err != nil {
return false, err
}
settings, ok := f.settings[req.VerifierBotID]
if !ok {
return false, domain.ErrVerifierNotFound
}
if !settings.Enabled {
return false, domain.ErrVerifierForbidden
}
existing, marked := f.marks[req.Peer]
if !req.Enabled {
// A revoke only touches this verifier's own mark, and a repeated revoke is a
// no-op rather than an error.
if !marked || existing.VerifierBotID != req.VerifierBotID {
return false, nil
}
delete(f.marks, req.Peer)
f.notify(req.Peer)
return true, nil
}
description, err := settings.DescriptionFor(req.CustomDescription)
if err != nil {
return false, err
}
if marked && existing.VerifierBotID == req.VerifierBotID &&
existing.IconDocumentID == settings.IconDocumentID && existing.Description == description {
return false, nil
}
if f.limit > 0 && !marked && f.countFor(req.VerifierBotID) >= f.limit {
return false, domain.ErrCustomVerificationLimit
}
f.marks[req.Peer] = domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
IconDocumentID: settings.IconDocumentID,
Description: description,
GrantedByUserID: req.CallerUserID,
}
f.notify(req.Peer)
return true, nil
}
// notify reproduces the service's post-commit push.
func (f *fakeBotVerifications) notify(peer domain.Peer) {
if f.notifier == nil {
return
}
_ = f.notifier.NotifyPeerBotVerification(context.Background(), peer)
}
func (f *fakeBotVerifications) countFor(verifierBotID int64) int {
n := 0
for _, mark := range f.marks {
if mark.VerifierBotID == verifierBotID {
n++
}
}
return n
}
var _ BotVerificationService = (*fakeBotVerifications)(nil)
// botVerificationFixture wires the real users/bots/channels services next to the
// fake verification service, so the ownership checks bots.setCustomVerification
// depends on are the production ones.
type botVerificationFixture struct {
router *Router
verify *fakeBotVerifications
bots *botsapp.Service
owner domain.User
stranger domain.User
target domain.User
bot domain.User
foreign domain.User
}
func newBotVerificationFixture(t *testing.T, verify BotVerificationService) botVerificationFixture {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
botStore := memory.NewBotStore(userStore)
dialogs := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogs)
bots := botsapp.NewService(userStore, botStore, messageStore)
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
stranger, err := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Stranger"})
if err != nil {
t.Fatalf("create stranger: %v", err)
}
target, err := userStore.Create(ctx, domain.User{AccessHash: 9103, Phone: "15550009103", FirstName: "Target", Username: "target_shop"})
if err != nil {
t.Fatalf("create target: %v", err)
}
bot, _, err := bots.CreateBot(ctx, owner.ID, "Verifier Bot", "verifier_shape_bot")
if err != nil {
t.Fatalf("create verifier bot: %v", err)
}
foreign, _, err := bots.CreateBot(ctx, stranger.ID, "Foreign Bot", "foreign_shape_bot")
if err != nil {
t.Fatalf("create foreign bot: %v", err)
}
fake, _ := verify.(*fakeBotVerifications)
deps := Deps{
Users: appusers.NewService(userStore),
Bots: bots,
Channels: channels,
}
if verify != nil {
deps.BotVerifications = verify
}
return botVerificationFixture{
router: New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, deps, zaptest.NewLogger(t), clock.System),
verify: fake,
bots: bots,
owner: owner,
stranger: stranger,
target: target,
bot: bot,
foreign: foreign,
}
}
// enableVerifier grants the fake verifier status the way the operator would.
func (f botVerificationFixture) enableVerifier(botID, icon int64, canModifyDescription bool) {
f.verify.settings[botID] = domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: icon,
CompanyName: "Acme Trust",
DefaultDescription: "Verified by Acme Trust",
CanModifyCustomDescription: canModifyDescription,
Enabled: true,
}
}
func setCustomVerificationRequest(peer tg.InputPeerClass, bot tg.InputUserClass, enabled bool, description string) *tg.BotsSetCustomVerificationRequest {
req := &tg.BotsSetCustomVerificationRequest{Peer: peer}
if bot != nil {
req.SetBot(bot)
}
req.SetEnabled(enabled)
if description != "" {
req.SetCustomDescription(description)
}
return req
}
// TestBotsSetCustomVerificationOwnerGrantsAndRevokes is the main happy path: the
// owner of a verifier bot marks a peer, the repeat call remains successful, and
// the revoke removes exactly that mark.
//
// The Bool result is asserted rather than checking only err == nil: official
// clients require BoolTrue even when the requested state was already applied.
func TestBotsSetCustomVerificationOwnerGrantsAndRevokes(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550001, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("owner grant = %v,%v, want true,nil", ok, err)
}
mark, exists := f.verify.marks[peer]
if !exists {
t.Fatalf("grant stored nothing: %+v", f.verify.marks)
}
if mark.VerifierBotID != f.bot.ID || mark.IconDocumentID != 5550001 {
t.Fatalf("stored mark = %+v, want verifier %d icon 5550001", mark, f.bot.ID)
}
if mark.Description != "Verified by Acme Trust" {
t.Fatalf("stored description = %q, want the verifier default", mark.Description)
}
if mark.GrantedByUserID != f.owner.ID {
t.Fatalf("granted by = %d, want calling owner %d", mark.GrantedByUserID, f.owner.ID)
}
// The method reports successful processing, including an idempotent replay.
// Official clients treat BoolFalse as failure and do not infer "unchanged".
repeat, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !repeat {
t.Fatalf("repeated grant = %v,%v, want true,nil", repeat, err)
}
revoked, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
if err != nil || !revoked {
t.Fatalf("revoke = %v,%v, want true,nil", revoked, err)
}
if _, exists := f.verify.marks[peer]; exists {
t.Fatalf("revoke left the mark behind: %+v", f.verify.marks)
}
repeatRevoke, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
if err != nil || !repeatRevoke {
t.Fatalf("repeated revoke = %v,%v, want true,nil", repeatRevoke, err)
}
}
// TestBotsSetCustomVerificationBotCallerActsAsItself covers the other caller the TL
// constructor allows: a bot invoking without bot:flags.0, which must resolve to the
// calling bot's own id.
func TestBotsSetCustomVerificationBotCallerActsAsItself(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550002, true)
botCtx := WithUserID(context.Background(), f.bot.ID)
ok, err := f.router.onBotsSetCustomVerification(botCtx,
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, ""))
if err != nil || !ok {
t.Fatalf("bot self grant = %v,%v, want true,nil", ok, err)
}
mark := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}]
if mark.VerifierBotID != f.bot.ID {
t.Fatalf("verifier id = %d, want the calling bot %d", mark.VerifierBotID, f.bot.ID)
}
if mark.GrantedByUserID != f.bot.ID {
t.Fatalf("granted by = %d, want the calling bot %d", mark.GrantedByUserID, f.bot.ID)
}
}
// TestBotsSetCustomVerificationRejectsForeignBot pins the ownership boundary: naming
// somebody else's bot is BOT_INVALID, and nothing reaches the service.
func TestBotsSetCustomVerificationRejectsForeignBot(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.foreign.ID, 5550003, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, ""))
if ok || !tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("foreign bot = %v,%v, want false,BOT_INVALID", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("foreign bot reached the service %d times", f.verify.setCalls)
}
if len(f.verify.marks) != 0 {
t.Fatalf("foreign bot minted a mark: %+v", f.verify.marks)
}
}
// TestBotsSetCustomVerificationRejectsNonVerifier covers a bot the operator never
// granted verifier status, and a verifier the operator switched off: both are
// BOT_VERIFIER_FORBIDDEN, because in both cases the bot may not verify anything.
func TestBotsSetCustomVerificationRejectsNonVerifier(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("non-verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
disabled := f.verify.settings
f.enableVerifier(f.bot.ID, 5550004, true)
settings := disabled[f.bot.ID]
settings.Enabled = false
disabled[f.bot.ID] = settings
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("disabled verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if len(f.verify.marks) != 0 {
t.Fatalf("forbidden verifier minted a mark: %+v", f.verify.marks)
}
}
// TestBotsSetCustomVerificationRejectsInvalidPeer covers both invalid-target paths:
// an unresolvable inputPeer at the edge, and a peer the domain refuses to verify.
// Both must read PEER_ID_INVALID to a client -- the verifier is fine, the target is
// not.
func TestBotsSetCustomVerificationRejectsInvalidPeer(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550005, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("empty peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("empty peer reached the service %d times", f.verify.setCalls)
}
// A stored-state rejection maps to the same code, so a client cannot tell the
// two apart and cannot probe which peers exist.
f.verify.err = domain.ErrCustomVerificationTargetInvalid
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("domain-rejected peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}
// TestBotsSetCustomVerificationRejectsForbiddenDescription pins the
// can_modify_custom_description permission: a verifier that may only apply the
// operator default is refused when it supplies its own text, and the refusal is a
// documented BOT_VERIFIER_FORBIDDEN error.
func TestBotsSetCustomVerificationRejectsForbiddenDescription(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550006, false)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Hand-written blurb"))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("forbidden description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if code, _ := tgerr.AsType(err, "BOT_VERIFIER_FORBIDDEN"); code == nil || code.Code != 403 {
t.Fatalf("forbidden description error = %+v, want code 403", code)
}
if len(f.verify.marks) != 0 {
t.Fatalf("rejected description still minted a mark: %+v", f.verify.marks)
}
// Without the description the very same call goes through with the default text.
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
if err != nil || !ok {
t.Fatalf("default description = %v,%v, want true,nil", ok, err)
}
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Verified by Acme Trust" {
t.Fatalf("stored description = %q, want the operator default", got)
}
// A verifier that IS allowed to override stores its own text.
f.enableVerifier(f.bot.ID, 5550006, true)
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Official reseller"))
if err != nil || !ok {
t.Fatalf("custom description = %v,%v, want true,nil", ok, err)
}
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Official reseller" {
t.Fatalf("stored description = %q, want the verifier text", got)
}
}
// TestBotsSetCustomVerificationRejectsRevokeWithDescription pins the edge-side shape
// validation: a revoke that still carries a description is a caller bug (usually a
// forgotten enabled flag) and is reported rather than silently reinterpreted.
func TestBotsSetCustomVerificationRejectsRevokeWithDescription(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550007, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, "why"))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("revoke with description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if f.verify.setCalls != 0 {
t.Fatalf("malformed request reached the service %d times", f.verify.setCalls)
}
}
// TestBotsSetCustomVerificationReportsLimit keeps the local per-verifier bound
// behind the only applicable documented method error.
func TestBotsSetCustomVerificationReportsLimit(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 5550008, true)
f.verify.limit = 1
ownerCtx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); err != nil || !ok {
t.Fatalf("first grant = %v,%v, want true,nil", ok, err)
}
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.stranger), inputUser(f.bot), true, ""))
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("over-limit grant = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
}
func TestBotsSetCustomVerificationRejectsNilRequest(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
ok, err := f.router.onBotsSetCustomVerification(
WithUserID(context.Background(), f.owner.ID),
nil,
)
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("nil request = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}
// TestBotsSetCustomVerificationWithoutServiceStaysForbidden is the degradation half
// of the RPC: with no verification service wired no bot can be a verifier, so the
// answer must be exactly what it was before the feature existed.
func TestBotsSetCustomVerificationWithoutServiceStaysForbidden(t *testing.T) {
f := newBotVerificationFixture(t, nil)
if f.router.deps.BotVerifications != nil {
t.Fatal("fixture wired a verification service, want nil")
}
ownerCtx := WithUserID(context.Background(), f.owner.ID)
botCtx := WithUserID(context.Background(), f.bot.ID)
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); ok ||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("owner call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if ok, err := f.router.onBotsSetCustomVerification(botCtx,
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, "")); ok ||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("bot call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
// The ownership and peer checks still run first, so their codes are unchanged too.
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, "")); ok ||
!tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("foreign bot without service = %v,%v, want false,BOT_INVALID", ok, err)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, "")); ok ||
!tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("empty peer without service = %v,%v, want false,PEER_ID_INVALID", ok, err)
}
}

View file

@ -61,6 +61,25 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
}
botUserID := callback.BotUserID
// 内置进程内service bot 分支:@verifybot 这类 bot 没有 MTProto session、也没有
// Bot API 消费者,走下面的「推 updateBotCallbackQuery + 挂起 25s」必然超时回
// BOT_RESPONSE_TIMEOUT。因此在 registerContext / 推送之前同步问 responder点击本身
// 已由 resolveBotCallbackQuery 校验过(消息在请求者自己的盒里、对端正是该 bot、data
// 确实出现在该消息的 inline keyboard 中),此处只是把答案交给拥有该 bot 的实现。
// 不注册 query id外部 setBotCallbackAnswer 因此无法伪造/覆盖内置 bot 的应答。
if r.deps.ServiceBotCallbacks != nil && r.deps.ServiceBotCallbacks.HandlesBot(botUserID) {
ans, handled, err := r.deps.ServiceBotCallbacks.OnCallbackQuery(ctx, callback)
if err != nil {
r.log.Warn("service bot callback query",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
return nil, internalErr()
}
if !handled {
return nil, dataInvalidErr()
}
return tgBotCallbackAnswer(ans), nil
}
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
if err != nil {
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))

View file

@ -28,6 +28,31 @@ func rightsNotModifiedErr() error { return tgerr.New(400, "RIGHTS_NOT_MOD
func botVerifierForbiddenErr() error { return tgerr.New(403, "BOT_VERIFIER_FORBIDDEN") }
func userPermissionDeniedErr() error { return tgerr.New(403, "USER_PERMISSION_DENIED") }
// setCustomVerificationErr maps the third-party verification domain errors onto TL.
//
// The public method documents only BOT_INVALID, BOT_VERIFIER_FORBIDDEN and
// PEER_ID_INVALID. Domain detail must not leak invented error names that official
// clients do not handle.
func setCustomVerificationErr(err error) error {
switch {
case errors.Is(err, domain.ErrVerifierForbidden),
errors.Is(err, domain.ErrVerifierNotFound),
errors.Is(err, domain.ErrVerificationIconNotFound),
errors.Is(err, domain.ErrVerificationIconInactive),
errors.Is(err, domain.ErrVerificationIconInvalid),
errors.Is(err, domain.ErrVerifierDescriptionForbidden),
errors.Is(err, domain.ErrCustomVerificationRequestInvalid),
errors.Is(err, domain.ErrCustomVerificationLimit):
return botVerifierForbiddenErr()
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrBotNotFound), errors.Is(err, domain.ErrVerifierSettingsInvalid):
return botInvalidErr()
default:
return internalErr()
}
}
func setBotCommandsErr(err error) error {
if errors.Is(err, domain.ErrBotCommandInvalid) {
return botCommandInvalidErr()

View file

@ -77,15 +77,33 @@ func (r *Router) onBotsSetBotGroupDefaultAdminRights(ctx context.Context, _ tg.C
return false, rightsNotModifiedErr()
}
// onBotsReorderUsernames reorders a bot's collectible usernames. A bot is a user
// peer in the registry, so the only difference from account.reorderUsernames is
// the ownership gate: resolveOwnedBotUser already rejects a caller who does not
// own the bot.
//
// With no registry wired the historical USERNAME_NOT_MODIFIED answer is kept --
// a bot with a single editable username genuinely has nothing to reorder.
func (r *Router) onBotsReorderUsernames(ctx context.Context, req *tg.BotsReorderUsernamesRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
if req == nil {
return false, botInvalidErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return false, err
}
return false, usernameNotModifiedErr()
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}
if err := r.reorderRegistryUsernames(ctx, peer, req.Order); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUsernameRequest) (bool, error) {
@ -93,10 +111,21 @@ func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUse
if err != nil {
return false, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
if req == nil {
return false, botInvalidErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return false, err
}
return false, usernameNotModifiedErr()
if r.deps.Usernames == nil {
return false, usernameNotModifiedErr()
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}
if err := r.toggleRegistryUsername(ctx, peer, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onBotsCanSendMessage(ctx context.Context, bot tg.InputUserClass) (bool, error) {
@ -481,22 +510,72 @@ func (r *Router) onBotsUpdateStarRefProgram(ctx context.Context, req *tg.BotsUpd
return nil, botInvalidErr()
}
// onBotsSetCustomVerification answers bots.setCustomVerification#8b89dfbd: a
// verifier bot adding or removing its own third-party mark on a peer
// (core.telegram.org/api/bots/verification).
//
// The TL constructor allows exactly two callers, and the schema encodes which:
// bot:flags.0?InputUser "must not be set if invoked by a bot, must be set to the ID
// of an owned bot if invoked by a user". Both branches are resolved to the same
// verifier bot id before anything is written, so a user can only ever act through a
// bot they own and a bot can only ever act as itself.
//
// Bool reports successful handling. Idempotent retries still return true; both
// official clients and the Bot API interpret boolFalse as failure.
func (r *Router) onBotsSetCustomVerification(ctx context.Context, req *tg.BotsSetCustomVerificationRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req == nil {
return false, peerIDInvalidErr()
}
var verifierBotID int64
if bot, ok := req.GetBot(); ok {
if _, err := r.resolveOwnedBotUser(ctx, userID, bot); err != nil {
owned, err := r.resolveOwnedBotUser(ctx, userID, bot)
if err != nil {
return false, err
}
} else if _, err := r.callerBotID(ctx); err != nil {
verifierBotID = owned.ID
} else {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
verifierBotID = botID
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return false, err
if r.deps.BotVerifications == nil {
// Unwired deployment: no bot can be a verifier, which is exactly what
// BOT_VERIFIER_FORBIDDEN says.
return false, botVerifierForbiddenErr()
}
return false, botVerifierForbiddenErr()
setRequest := domain.SetCustomVerificationRequest{
VerifierBotID: verifierBotID,
Peer: peer,
Enabled: req.GetEnabled(),
CallerUserID: userID,
}
if description, ok := req.GetCustomDescription(); ok {
setRequest.CustomDescription = description
}
// Shape-only validation at the edge (peer kind, description length, revoke that
// still carries a description) so a malformed request gets a deterministic TL
// error regardless of how strict the wired service is.
if err := setRequest.Validate(); err != nil {
return false, setCustomVerificationErr(err)
}
_, err = r.deps.BotVerifications.SetCustomVerification(ctx, setRequest)
if err != nil {
return false, setCustomVerificationErr(err)
}
// The push belongs to the service, not here: it owns the same invalidation for
// all three drivers (this RPC, the bot dialog and the admin panel), so pushing
// again would fan out to the whole audience twice per change.
return true, nil
}
func (r *Router) onBotsGetBotRecommendations(ctx context.Context, _ tg.InputUserClass) (tg.UsersUsersClass, error) {

View file

@ -116,7 +116,7 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne
chats = append(chats, tgChannelChatForView(userID, view))
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
@ -172,11 +172,12 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
r.applyBotVerificationToChannelFull(ctx, ref.ID, &full)
r.applyAndroidChannelReactionEditorCompat(ctx, &full, cached.canChangeInfo)
chats := append([]tg.ChatClass(nil), cached.chats...)
chats = r.appendLinkedDiscussionChat(ctx, userID, ref.ID, chats)
r.trackChannelInterest(ctx, userID, ref.ID)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
FullChat: &full,
Chats: chats,
@ -220,8 +221,11 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
}
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full)
// After StoreIfEpoch on purpose: the mark stays out of the per-(viewer,channel)
// projection cache so a revoked badge cannot outlive the change by a cache TTL.
r.applyBotVerificationToChannelFull(ctx, view.Channel.ID, full)
r.applyAndroidChannelReactionEditorCompat(ctx, full, canChangeInfo)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
FullChat: full,
Chats: chats,

View file

@ -59,9 +59,11 @@ func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg
return nil, channelInviteErr(err)
}
if res.Already {
// chatInviteAlready#5a686d7c wraps a full Chat, so the badge flags already
// travel through tgChannelChat; nothing to re-apply here.
return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil
}
return &tg.ChatInvite{
invite := &tg.ChatInvite{
Channel: true,
Broadcast: res.Channel.Broadcast,
Megagroup: res.Channel.Megagroup,
@ -71,7 +73,37 @@ func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg
About: res.Channel.About,
Photo: &tg.PhotoEmpty{},
ParticipantsCount: res.Channel.ParticipantsCount,
}, nil
}
// chatInvite#5c9d3702 carries verified:flags.7 / scam:flags.8 / fake:flags.9.
// A non-member sees only this preview, so the peer's moderation and official
// verification state must already be visible here: without it the badge (or the
// scam/fake warning) appears only after joining, which is exactly backwards.
// Set*, not raw field assignment, so Flags stays consistent before Encode.
applyChatInviteModerationFlags(invite, res.Channel)
// bot_verification:flags.13 extends the same reasoning one field further: the
// third-party mark is part of what identifies the peer, so the preview a
// non-member sees has to carry it as well.
r.applyBotVerificationToChatInvite(ctx, invite, res.Channel.ID)
return invite, nil
}
// applyChatInviteModerationFlags mirrors the persistent channel record's official
// verification and moderation flags onto an invite preview. Absent flags are left
// unset rather than explicitly cleared, so the encoded chatInvite matches what an
// official server sends for an unflagged peer.
func applyChatInviteModerationFlags(invite *tg.ChatInvite, ch domain.Channel) {
if invite == nil {
return
}
if ch.Verified {
invite.SetVerified(true)
}
if ch.Scam {
invite.SetScam(true)
}
if ch.Fake {
invite.SetFake(true)
}
}
func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.MessagesChatInviteJoinResultClass, error) {

View file

@ -0,0 +1,146 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// inviteBadgeChannels answers messages.checkChatInvite from a canned persistent
// channel record, so the test observes exactly the flags the projection derives.
type inviteBadgeChannels struct {
ChannelsService
result domain.CheckChannelInviteResult
}
func (s *inviteBadgeChannels) CheckInvite(_ context.Context, _ int64, _ string, _ int) (domain.CheckChannelInviteResult, error) {
return s.result, nil
}
func checkChatInvitePreview(t *testing.T, ch domain.Channel) *tg.ChatInvite {
t.Helper()
r := New(Config{}, Deps{Channels: &inviteBadgeChannels{
result: domain.CheckChannelInviteResult{
Channel: ch,
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: ch.ID},
},
}}, zap.NewNop(), clock.System)
res, err := r.onMessagesCheckChatInvite(WithUserID(context.Background(), 1001), "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
invite, ok := res.(*tg.ChatInvite)
if !ok {
t.Fatalf("invite = %T %+v", res, res)
}
// Round-trip through the wire so the assertions read the encoded flags word
// rather than only the Go struct fields: a raw field assignment would pass the
// struct check and still ship flags.7/8/9 unset.
buf := &bin.Buffer{}
if err := invite.Encode(buf); err != nil {
t.Fatalf("encode chatInvite: %v", err)
}
decoded := &tg.ChatInvite{}
if err := decoded.Decode(buf); err != nil {
t.Fatalf("decode chatInvite: %v", err)
}
return decoded
}
// TestCheckChatInvitePreviewCarriesLayer228BadgeFlags pins chatInvite#5c9d3702
// verified:flags.7 / scam:flags.8 / fake:flags.9 onto the invite preview, so an
// official client shows the badge before the user joins rather than after.
func TestCheckChatInvitePreviewCarriesLayer228BadgeFlags(t *testing.T) {
if tg.ChatInviteTypeID != 0x5c9d3702 {
t.Fatalf("chatInvite constructor id = %#x", tg.ChatInviteTypeID)
}
verified := checkChatInvitePreview(t, domain.Channel{
ID: 4004, AccessHash: 44, Title: "Official", Username: "official",
Broadcast: true, ParticipantsCount: 7, Verified: true,
})
if !verified.GetVerified() || !verified.Verified {
t.Fatalf("verified invite = %+v", verified)
}
if verified.GetScam() || verified.GetFake() {
t.Fatalf("verified invite leaked moderation flags: %+v", verified)
}
if !verified.Channel || !verified.Broadcast || !verified.Public ||
verified.Title != "Official" || verified.ParticipantsCount != 7 {
t.Fatalf("verified invite lost unrelated fields: %+v", verified)
}
flagged := checkChatInvitePreview(t, domain.Channel{
ID: 4005, AccessHash: 45, Title: "Flagged", Megagroup: true,
Scam: true, Fake: true,
})
if !flagged.GetScam() || !flagged.GetFake() {
t.Fatalf("flagged invite = %+v", flagged)
}
if flagged.GetVerified() {
t.Fatalf("flagged invite claims verification: %+v", flagged)
}
if !flagged.Megagroup || flagged.Public {
t.Fatalf("flagged invite lost unrelated fields: %+v", flagged)
}
}
// TestCheckChatInvitePreviewLeavesUnflaggedChannelClean is the negative half: an
// ordinary channel must not carry flags.7/8/9 at all, so the encoded preview stays
// byte-identical to what an official server sends for an unflagged peer.
func TestCheckChatInvitePreviewLeavesUnflaggedChannelClean(t *testing.T) {
plain := checkChatInvitePreview(t, domain.Channel{
ID: 4006, AccessHash: 46, Title: "Plain", Broadcast: true,
})
if plain.GetVerified() || plain.Verified {
t.Fatalf("unverified channel exposed verified: %+v", plain)
}
if plain.GetScam() || plain.GetFake() || plain.Scam || plain.Fake {
t.Fatalf("unflagged channel exposed moderation flags: %+v", plain)
}
if plain.Flags.Has(7) || plain.Flags.Has(8) || plain.Flags.Has(9) {
t.Fatalf("unflagged channel set badge bits in flags word: %d", plain.Flags)
}
}
// TestCheckChatInviteAlreadyKeepsChannelBadge guards the other branch: a member
// gets chatInviteAlready#5a686d7c, whose Chat already carries
// channel#d49f34c6 verified:flags.7 through tgChannelChat, so the flags must not be
// applied twice or lost.
func TestCheckChatInviteAlreadyKeepsChannelBadge(t *testing.T) {
const channelID = int64(4007)
r := New(Config{}, Deps{Channels: &inviteBadgeChannels{
result: domain.CheckChannelInviteResult{
Channel: domain.Channel{
ID: channelID, AccessHash: 47, Title: "Official", Username: "official",
Broadcast: true, Verified: true,
},
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: channelID},
Already: true,
Self: domain.ChannelMember{
ChannelID: channelID, UserID: 1001, Status: domain.ChannelMemberActive,
},
},
}}, zap.NewNop(), clock.System)
res, err := r.onMessagesCheckChatInvite(WithUserID(context.Background(), 1001), "hash")
if err != nil {
t.Fatalf("check chat invite: %v", err)
}
already, ok := res.(*tg.ChatInviteAlready)
if !ok {
t.Fatalf("invite = %T %+v", res, res)
}
channel, ok := already.Chat.(*tg.Channel)
if !ok || channel.ID != channelID {
t.Fatalf("chat = %T %+v", already.Chat, already.Chat)
}
if !channel.Verified || channel.Scam || channel.Fake {
t.Fatalf("already-member channel badge = %+v", channel)
}
}

View file

@ -199,7 +199,7 @@ func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.Channels
users = append(users, r.tgUsersForIDs(ctx, userID, missing)...)
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil)
r.applyPeerReadModels(ctx, userID, users, nil)
r.log.Debug("channels.getParticipants result",
zap.Int64("channel_id", ref.ID),
zap.String("filter", string(filter.Kind)),
@ -237,7 +237,7 @@ func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsG
}
participant := tgChannelParticipant(userID, member)
users := r.tgUsersForIDs(ctx, userID, channelParticipantUserRefs(participant))
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil)
r.applyPeerReadModels(ctx, userID, users, nil)
return &tg.ChannelsChannelParticipant{
Participant: participant,
Users: users,
@ -597,7 +597,7 @@ func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetA
events := tgChannelAdminLogEvents(userID, res.Events)
chats := []tg.ChatClass{tgChannelChatMin(userID, res.Channel)}
users := r.channelAdminLogUsers(ctx, userID, res.Events)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, chats)
r.applyPeerReadModels(ctx, userID, users, chats)
return &tg.ChannelsAdminLogResults{
Events: events,
Chats: chats,

View file

@ -102,28 +102,66 @@ func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.Channel
return true, nil
}
// onChannelsReorderUsernames rewrites the channel's collectible username order.
// Permissions are the ordinary change_info gate every other channels.* setting
// uses (channelChangeInfoView).
//
// With no username registry wired the handler keeps its historical accept-and-
// ignore answer: the channel then owns exactly one editable username, whose order
// is not expressible, so reporting success is both true and what shipped clients
// already saw.
func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) {
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if len(req.Order) > maxChannelUsernameOrder {
return false, limitInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
_, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.reorderRegistryUsernames(ctx, peer, req.Order); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) {
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if req.Username != "" && !validChannelManagementUsername(req.Username) {
return false, usernameInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
_, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.toggleRegistryUsername(ctx, peer, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsDeactivateAllUsernames(ctx context.Context, input tg.InputChannelClass) (bool, error) {
if _, _, err := r.channelChangeInfoView(ctx, input); err != nil {
_, view, err := r.channelChangeInfoView(ctx, input)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.deactivateAllRegistryUsernames(ctx, peer); err != nil {
return false, err
}
return true, nil

View file

@ -47,8 +47,13 @@ func (r *Router) channelStateMutationUpdates(ctx context.Context, userID int64,
r.invalidateRPCProjectionForChannel(channel.LinkedMonoforumID)
}
mono, includeMono := r.linkedMonoforumForChannelState(ctx, userID, channel)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, userID, channel, mono, includeMono)
return r.channelStateUpdatesWithLinkedMonoforum(userID, channel, mono, includeMono)
// One read for the whole fan-out plus the returned updates: the third-party
// verification mark is a peer-wide fact, and the per-recipient builder must stay
// read-free (see channelStateUpdatesWithLinkedMonoforum).
icon := r.peerBotVerificationIcon(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID})
usernames := r.channelStateUsernameRegistry(ctx, channel, mono, includeMono)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, userID, channel, mono, includeMono, icon, usernames)
return r.channelStateUpdatesWithLinkedMonoforum(userID, channel, mono, includeMono, icon, usernames)
}
func (r *Router) channelPaidMessagesPriceUpdates(ctx context.Context, userID int64, res domain.ChannelPaidMessagesPriceResult) tg.UpdatesClass {

View file

@ -445,18 +445,30 @@ func uniqueRecipientIDs(ids []int64) []int64 {
}
func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) {
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false)
// The third-party verification icon is resolved once here, outside the
// per-recipient builder: see channelStateUpdatesWithLinkedMonoforum.
icon := r.peerBotVerificationIcon(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID})
usernames := r.channelStateUsernameRegistry(ctx, channel, domain.Channel{}, false)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false, icon, usernames)
}
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) {
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool, botVerificationIcon int64, usernames map[domain.Peer][]domain.Username) {
if r.deps.Channels == nil || channel.ID == 0 {
return
}
r.pushChannelUpdates(ctx, originUserID, channel.ID, []int64{originUserID}, func(viewerUserID int64) *tg.Updates {
return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono)
return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono, botVerificationIcon, usernames)
})
}
func (r *Router) channelStateUsernameRegistry(ctx context.Context, channel domain.Channel, mono domain.Channel, includeMono bool) map[domain.Peer][]domain.Username {
peers := []domain.Peer{{Type: domain.PeerTypeChannel, ID: channel.ID}}
if includeMono && mono.ID != 0 && mono.ID != channel.ID {
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID})
}
return r.usernameRegistryMap(ctx, peers)
}
func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass {
if r.deps.Users == nil || len(ids) == 0 {
return nil

View file

@ -145,7 +145,11 @@ func appendChannelStateUpdates(dst *tg.Updates, extra *tg.Updates) {
}
}
func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) *tg.Updates {
// channelStateUpdatesWithLinkedMonoforum builds one recipient's channel-state
// update. Peer-wide overlays are passed in already resolved rather than read
// here: this builder runs once per online recipient, so reads inside it would
// turn one state change into one query per member.
func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool, botVerificationIcon int64, usernames map[domain.Peer][]domain.Username) *tg.Updates {
updates := r.channelStateUpdates(viewerUserID, channel)
// 母广播频道有/曾有关联 monoforum 时,按完整(非 min)形态下发。关闭 Direct Messages 时
// linked_monoforum_id 在投影里被隐藏,只有完整频道对象才能覆盖客户端缓存里旧的 linked_monoforum_id,
@ -158,6 +162,12 @@ func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, chan
if includeMono {
updates.Chats = appendUniqueTGChats(updates.Chats, tgChannelChat(viewerUserID, mono, nil))
}
// The third-party mark travels with the state mutation for the same reason
// verified:flags.7 does -- it is part of how the peer identifies itself -- but it
// lives in a read model rather than on domain.Channel, so it is stamped on here
// instead of being projected from the row.
applyBotVerificationIconToChannelChats(updates.Chats, channel.ID, botVerificationIcon)
applyUsernamesFromRegistry(nil, updates.Chats, usernames)
return updates
}

View file

@ -0,0 +1,709 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeUsernameRegistry is an in-memory UsernameRegistryService. It enforces the
// same domain rules the real registry does, so the RPC tests exercise the actual
// error mapping rather than hand-rolled sentinels.
type fakeUsernameRegistry struct {
byPeer map[domain.Peer][]domain.Username
collectibles map[string]domain.CollectibleUsername
// err, when set, fails every read. Used for the degradation tests.
err error
// batchCalls / peerCalls count read fan-out so the tests can assert no N+1.
batchCalls int
peerCalls int
}
func newFakeUsernameRegistry() *fakeUsernameRegistry {
return &fakeUsernameRegistry{
byPeer: make(map[domain.Peer][]domain.Username),
collectibles: make(map[string]domain.CollectibleUsername),
}
}
func (f *fakeUsernameRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
f.peerCalls++
if f.err != nil {
return nil, f.err
}
return f.byPeer[peer], nil
}
func (f *fakeUsernameRegistry) UsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
f.batchCalls++
if f.err != nil {
return nil, f.err
}
out := make(map[domain.Peer][]domain.Username, len(peers))
for _, peer := range peers {
if list, ok := f.byPeer[peer]; ok {
out[peer] = list
}
}
return out, nil
}
func (f *fakeUsernameRegistry) ToggleUsername(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if f.err != nil {
return false, f.err
}
current := f.byPeer[peer]
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
changed := false
next := append([]domain.Username(nil), current...)
for i := range next {
if next[i].Username != domain.NormalizeUsername(username) {
continue
}
if next[i].Active != active {
next[i].Active = active
changed = true
}
}
f.byPeer[peer] = next
return changed, nil
}
func (f *fakeUsernameRegistry) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
if f.err != nil {
return false, f.err
}
current := f.byPeer[peer]
next, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return false, err
}
// Same contract as both real backends: the renumbering is always persisted,
// and "changed" is only about what a client can see.
changed := !domain.SameUsernameOrder(current, next)
f.byPeer[peer] = next
return changed, nil
}
func (f *fakeUsernameRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
if f.err != nil {
return false, f.err
}
next := append([]domain.Username(nil), f.byPeer[peer]...)
changed := false
for i := range next {
if next[i].Collectible() && next[i].Active {
next[i].Active = false
changed = true
}
}
f.byPeer[peer] = next
return changed, nil
}
func (f *fakeUsernameRegistry) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) {
if f.err != nil {
return domain.CollectibleUsername{}, f.err
}
// Usernames are case-insensitive in the registry, as they are in the store.
asset, ok := f.collectibles[strings.ToLower(domain.NormalizeUsername(username))]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
var _ UsernameRegistryService = (*fakeUsernameRegistry)(nil)
func TestTgUsernamesFromRegistryFollowsStoredOrder(t *testing.T) {
// Legacy numbering, which is what every peer that never reordered carries:
// the editable slot and the first collectible share sort_order 0 and the
// editable slot wins the tie, so it still projects first.
assertUsernameVector(t,
[]domain.Username{
{Username: "second", Active: false, SortOrder: 1, CollectibleID: 22},
{Username: "editable_slot", Active: true, Editable: true, SortOrder: 0},
{Username: "first", Active: true, SortOrder: 0, CollectibleID: 11},
},
[]tg.Username{
{Editable: true, Active: true, Username: "editable_slot"},
{Editable: false, Active: true, Username: "first"},
{Editable: false, Active: false, Username: "second"},
})
// After a reorder made a collectible primary, stored order wins: clients read
// usernames[0] as the peer's primary username
// (core.telegram.org/api/fragment), so the editable slot must be able to
// leave that position.
assertUsernameVector(t,
[]domain.Username{
{Username: "second", Active: false, SortOrder: 2, CollectibleID: 22},
{Username: "editable_slot", Active: true, Editable: true, SortOrder: 1},
{Username: "first", Active: true, SortOrder: 0, CollectibleID: 11},
},
[]tg.Username{
{Editable: false, Active: true, Username: "first"},
{Editable: true, Active: true, Username: "editable_slot"},
{Editable: false, Active: false, Username: "second"},
})
}
func assertUsernameVector(t *testing.T, list []domain.Username, want []tg.Username) {
t.Helper()
got := tgUsernamesFromRegistry(list, "editable_slot")
if len(got) != len(want) {
t.Fatalf("usernames = %+v, want %+v", got, want)
}
for i := range want {
if got[i].Username != want[i].Username || got[i].Editable != want[i].Editable || got[i].Active != want[i].Active {
t.Fatalf("usernames[%d] = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestTgUsernamesFromRegistryDegradesToScalar(t *testing.T) {
// Empty registry contribution must be byte-identical to the legacy vector.
if got, want := tgUsernamesFromRegistry(nil, "legacy"), tgUsernames("legacy"); len(got) != 1 || got[0] != want[0] {
t.Fatalf("empty registry = %+v, want %+v", got, want)
}
if got := tgUsernamesFromRegistry(nil, ""); got != nil {
t.Fatalf("empty registry with empty scalar = %+v, want nil", got)
}
// A registry list that normalizes away entirely also degrades rather than
// emitting an empty vector.
if got, want := tgUsernamesFromRegistry([]domain.Username{{Username: " "}}, "legacy"), tgUsernames("legacy"); len(got) != 1 || got[0] != want[0] {
t.Fatalf("blank registry rows = %+v, want %+v", got, want)
}
}
type usernameProjectionFixture struct {
router *Router
registry *fakeUsernameRegistry
owner domain.User
friend domain.User
}
func newUsernameProjectionFixture(t *testing.T, registry UsernameRegistryService) usernameProjectionFixture {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner", Username: "owner_slot"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend", Username: "friend_slot"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
fake, _ := registry.(*fakeUsernameRegistry)
return usernameProjectionFixture{
router: New(Config{}, Deps{
Users: appusers.NewService(userStore),
Usernames: registry,
}, zaptest.NewLogger(t), clock.System),
registry: fake,
owner: owner,
friend: friend,
}
}
func usernameStrings(list []tg.Username) []string {
out := make([]string, 0, len(list))
for _, item := range list {
out = append(out, item.Username)
}
return out
}
func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) {
registry := newFakeUsernameRegistry()
f := newUsernameProjectionFixture(t, registry)
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}] = []domain.Username{
{Username: "owner_slot", Editable: true, Active: true},
{Username: "nft", Active: true, SortOrder: 0, CollectibleID: 7},
}
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.friend.ID}] = []domain.Username{
{Username: "friend_slot", Editable: true, Active: true},
{Username: "gem", Active: false, SortOrder: 0, CollectibleID: 8},
}
ctx := WithUserID(context.Background(), f.owner.ID)
out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{
&tg.InputUserSelf{},
&tg.InputUser{UserID: f.friend.ID, AccessHash: f.friend.AccessHash},
})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(out) != 2 {
t.Fatalf("users = %d, want 2", len(out))
}
self := out[0].(*tg.User)
if _, ok := self.GetUsername(); ok {
t.Fatalf("self scalar username is set together with collectible vector")
}
vector, ok := self.GetUsernames()
if !ok {
t.Fatalf("self usernames unset, want registry vector")
}
if got := usernameStrings(vector); len(got) != 2 || got[0] != "owner_slot" || got[1] != "nft" {
t.Fatalf("self usernames = %v, want [owner_slot nft]", got)
}
if !vector[0].Editable || !vector[0].Active {
t.Fatalf("editable slot flags = %+v, want editable+active", vector[0])
}
if vector[1].Editable || !vector[1].Active {
t.Fatalf("collectible flags = %+v, want non-editable+active", vector[1])
}
friend := out[1].(*tg.User)
friendVector, _ := friend.GetUsernames()
if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem" {
t.Fatalf("friend usernames = %v, want [friend_slot gem]", got)
}
if friendVector[1].Active {
t.Fatalf("inactive collectible projected active: %+v", friendVector[1])
}
// Two users, one batch read: no N+1.
if registry.batchCalls != 1 || registry.peerCalls != 0 {
t.Fatalf("registry reads = batch %d / peer %d, want batch 1 / peer 0", registry.batchCalls, registry.peerCalls)
}
}
func TestUsersGetUsersDegradesWithoutRegistry(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}})
if err != nil {
t.Fatalf("get users: %v", err)
}
self := out[0].(*tg.User)
// Without a collectible registry the official legacy shape is scalar-only.
self.SetFlags()
vector, ok := self.GetUsernames()
if ok || len(vector) != 0 {
t.Fatalf("usernames = %+v (set %v), want vector absent", vector, ok)
}
if scalar, ok := self.GetUsername(); !ok || scalar != "owner_slot" {
t.Fatalf("scalar username = %q (set %v), want owner_slot", scalar, ok)
}
}
func TestUsersGetUsersDegradesWhenRegistryFails(t *testing.T) {
registry := newFakeUsernameRegistry()
registry.err = errors.New("registry unavailable")
f := newUsernameProjectionFixture(t, registry)
ctx := WithUserID(context.Background(), f.owner.ID)
out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{
&tg.InputUserSelf{},
&tg.InputUser{UserID: f.friend.ID, AccessHash: f.friend.AccessHash},
})
if err != nil {
t.Fatalf("get users must not fail when the registry does: %v", err)
}
if len(out) != 2 {
t.Fatalf("users = %d, want 2", len(out))
}
for i, want := range []string{"owner_slot", "friend_slot"} {
user := out[i].(*tg.User)
user.SetFlags()
vector, ok := user.GetUsernames()
if ok || len(vector) != 0 {
t.Fatalf("users[%d] usernames = %+v, want vector absent", i, vector)
}
if scalar, ok := user.GetUsername(); !ok || scalar != want {
t.Fatalf("users[%d] scalar username = %q (set %v), want %q", i, scalar, ok, want)
}
}
}
func TestFragmentGetCollectibleInfoRPC(t *testing.T) {
registry := newFakeUsernameRegistry()
f := newUsernameProjectionFixture(t, registry)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}
registry.collectibles["nfts"] = domain.CollectibleUsername{
ID: 7,
Username: "nfts",
Status: domain.CollectibleUsernameStatusOwned,
Owner: peer,
PurchaseDate: time.Unix(1700000000, 0),
Currency: domain.CollectibleCurrencyUSD,
Amount: 550000,
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
CryptoAmount: 1200000000,
URL: "https://fragment.example/username/nfts",
}
registry.byPeer[peer] = []domain.Username{{Username: "nfts", Active: false, CollectibleID: 7}}
ctx := WithUserID(context.Background(), f.owner.ID)
info, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "@NFTS"},
})
if err != nil {
t.Fatalf("get collectible info: %v", err)
}
if info.PurchaseDate != 1700000000 || info.Currency != domain.CollectibleCurrencyUSD || info.Amount != 550000 {
t.Fatalf("collectible info = %+v, want the stored purchase record", info)
}
if info.CryptoCurrency != domain.CollectibleCryptoCurrencyTON || info.CryptoAmount != 1200000000 {
t.Fatalf("collectible crypto = %s/%d, want TON/1200000000", info.CryptoCurrency, info.CryptoAmount)
}
if info.URL != "https://fragment.example/username/nfts" {
t.Fatalf("collectible url = %q", info.URL)
}
// Inactive collectibles are private to their current owner.
friendCtx := WithUserID(context.Background(), f.friend.ID)
if _, err := f.router.onFragmentGetCollectibleInfo(friendCtx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "nfts"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("inactive collectible seen by another user: %v, want COLLECTIBLE_NOT_FOUND", err)
}
registry.byPeer[peer][0].Active = true
if _, err := f.router.onFragmentGetCollectibleInfo(friendCtx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "nfts"},
}); err != nil {
t.Fatalf("active collectible hidden from another user: %v", err)
}
// A name with no collectible asset behind it is not occupied as a collectible.
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("non-collectible username err = %v, want COLLECTIBLE_NOT_FOUND", err)
}
// Syntactically impossible name is rejected before the registry is consulted.
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "a"},
}); !tgerr.Is(err, "COLLECTIBLE_INVALID") {
t.Fatalf("malformed username err = %v, want COLLECTIBLE_INVALID", err)
}
// Collectible phones do not exist in this server.
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectiblePhone{Phone: "15550002001"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("collectible phone err = %v, want COLLECTIBLE_NOT_FOUND", err)
}
}
func TestFragmentGetCollectibleInfoWithoutRegistry(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{
Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"},
}); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") {
t.Fatalf("no registry err = %v, want COLLECTIBLE_NOT_FOUND", err)
}
}
func TestAccountToggleAndReorderUsernamesRPC(t *testing.T) {
registry := newFakeUsernameRegistry()
f := newUsernameProjectionFixture(t, registry)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}
registry.byPeer[peer] = []domain.Username{
{Username: "owner_slot", Editable: true, Active: true},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "bravo", Active: true, SortOrder: 1, CollectibleID: 2},
}
ctx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "alpha", Active: false}); !ok || err != nil {
t.Fatalf("toggle collectible = %v,%v, want true,nil", ok, err)
}
if list := registry.byPeer[peer]; list[1].Active {
t.Fatalf("alpha still active after toggle: %+v", list)
}
// Toggling the same value again changes nothing.
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "alpha", Active: false}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("idempotent toggle = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
// The editable slot is not a collectible: account.updateUsername owns it.
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: false}); ok || !tgerr.Is(err, "USERNAME_INVALID") {
t.Fatalf("toggle editable slot = %v,%v, want false,USERNAME_INVALID", ok, err)
}
// An unknown name is not occupied.
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "ghost", Active: true}); ok || !tgerr.Is(err, "USERNAME_NOT_OCCUPIED") {
t.Fatalf("toggle unknown = %v,%v, want false,USERNAME_NOT_OCCUPIED", ok, err)
}
// alpha is inactive at this point, so the order does not have to mention it.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot", "bravo"}}); !ok || err != nil {
t.Fatalf("reorder = %v,%v, want true,nil", ok, err)
}
list := domain.SortUsernames(registry.byPeer[peer])
if len(list) != 3 || !list[0].Editable || list[1].Username != "bravo" || list[2].Username != "alpha" {
t.Fatalf("order after reorder = %+v, want editable, bravo, alpha", list)
}
// The editable slot may lead the order or follow a collectible.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"bravo", "owner_slot"}}); !ok || err != nil {
t.Fatalf("reorder with a collectible first = %v,%v, want true,nil", ok, err)
}
if list := domain.SortUsernames(registry.byPeer[peer]); list[0].Username != "bravo" || !list[1].Editable {
t.Fatalf("order after promoting a collectible = %+v, want bravo, editable, alpha", list)
}
// An order that omits an active username is still rejected.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"bravo"}}); ok || !tgerr.Is(err, "ORDER_INVALID") {
t.Fatalf("partial reorder = %v,%v, want false,ORDER_INVALID", ok, err)
}
// So is one naming something the peer does not own.
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot", "bravo", "ghost"}}); ok || !tgerr.Is(err, "ORDER_INVALID") {
t.Fatalf("reorder with an unknown name = %v,%v, want false,ORDER_INVALID", ok, err)
}
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{
Order: make([]string, domain.MaxPeerCollectibleUsernames+2),
}); ok || !tgerr.Is(err, "LIMIT_INVALID") {
t.Fatalf("oversized reorder = %v,%v, want false,LIMIT_INVALID", ok, err)
}
}
func TestAccountUsernameManagementWithoutRegistry(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: true}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("toggle without registry = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("reorder without registry = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
}
// TestCollectibleUsernameRPCsAreRegistered proves the three previously missing
// methods reach a handler through the real dispatcher rather than the unregistered
// fallback: an ordinary client calls them by wire constructor, not by Go method.
func TestCollectibleUsernameRPCsAreRegistered(t *testing.T) {
f := newUsernameProjectionFixture(t, nil)
ctx := WithUserID(context.Background(), f.owner.ID)
cases := []struct {
name string
req bin.Encoder
want string
}{
{name: "account.reorderUsernames", req: &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot"}}, want: "USERNAME_NOT_MODIFIED"},
{name: "account.toggleUsername", req: &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: true}, want: "USERNAME_NOT_MODIFIED"},
{name: "fragment.getCollectibleInfo", req: &tg.FragmentGetCollectibleInfoRequest{Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"}}, want: "COLLECTIBLE_NOT_FOUND"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
var in bin.Buffer
if err := tt.req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := f.router.Dispatch(ctx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) {
t.Fatalf("dispatch err = %v, want %s", err, tt.want)
}
})
}
}
func newCollectibleChannelFixture(t *testing.T, registry UsernameRegistryService) (*Router, domain.User, *tg.Channel) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Usernames: registry,
}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{
Broadcast: true,
Title: "Collectible Channel",
About: "about",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
if _, err := r.onChannelsUpdateUsername(ownerCtx, &tg.ChannelsUpdateUsernameRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Username: "chan_slot",
}); err != nil {
t.Fatalf("update channel username: %v", err)
}
return r, owner, channel
}
func TestChannelsUsernameManagementUsesRegistry(t *testing.T) {
registry := newFakeUsernameRegistry()
r, owner, channel := newCollectibleChannelFixture(t, registry)
ctx := WithUserID(context.Background(), owner.ID)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}
registry.byPeer[peer] = []domain.Username{
{Username: "chan_slot", Editable: true, Active: true},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 31},
{Username: "bravo", Active: true, SortOrder: 1, CollectibleID: 32},
}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: false}); !ok || err != nil {
t.Fatalf("toggle channel collectible = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: false}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("idempotent channel toggle = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "chan_slot", Active: false}); ok || !tgerr.Is(err, "USERNAME_INVALID") {
t.Fatalf("toggle channel editable slot = %v,%v, want false,USERNAME_INVALID", ok, err)
}
// alpha was just deactivated, so the order carries only the active names.
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot", "bravo"}}); !ok || err != nil {
t.Fatalf("reorder channel usernames = %v,%v, want true,nil", ok, err)
}
if list := domain.SortUsernames(registry.byPeer[peer]); list[1].Username != "bravo" {
t.Fatalf("channel order = %+v, want bravo first collectible", list)
}
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate all = %v,%v, want true,nil", ok, err)
}
for _, item := range registry.byPeer[peer] {
if item.Collectible() && item.Active {
t.Fatalf("collectible still active after deactivateAll: %+v", item)
}
}
// Deactivate-all is idempotent, while reorder follows the documented
// USERNAME_NOT_MODIFIED result for an unchanged order.
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("repeat deactivate all = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("repeat reorder after deactivateAll = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
// A non-admin must not reach the registry at all.
other := WithUserID(context.Background(), owner.ID+9999)
if _, err := r.onChannelsToggleUsername(other, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: true}); err == nil {
t.Fatalf("toggle as stranger err = nil, want permission failure")
}
}
func TestChannelsUsernameManagementWithoutRegistryKeepsLegacyAnswers(t *testing.T) {
r, owner, channel := newCollectibleChannelFixture(t, nil)
ctx := WithUserID(context.Background(), owner.ID)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "chan_slot", Active: true}); !ok || err != nil {
t.Fatalf("toggle without registry = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); !ok || err != nil {
t.Fatalf("reorder without registry = %v,%v, want true,nil", ok, err)
}
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate without registry = %v,%v, want true,nil", ok, err)
}
}
// TestChannelsDeactivateAllUsernamesIsIdempotent covers the report "I created a
// channel without a username and later could not set one": Telegram Desktop
// drives its channel-username editor by calling channels.deactivateAllUsernames
// first and only continuing when it answers true. A channel that owns no
// collectible username at all -- every freshly created channel -- has nothing to
// deactivate, and answering USERNAME_NOT_MODIFIED there aborted the whole flow
// client-side. Deactivating an empty set is success.
func TestChannelsDeactivateAllUsernamesIsIdempotent(t *testing.T) {
registry := newFakeUsernameRegistry()
r, owner, channel := newCollectibleChannelFixture(t, registry)
ctx := WithUserID(context.Background(), owner.ID)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}
// A channel with only its editable slot: no collectible username exists.
registry.byPeer[peer] = []domain.Username{{Username: "chan_slot", Editable: true, Active: true}}
for attempt := 0; attempt < 2; attempt++ {
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate all with nothing collectible (attempt %d) = %v,%v, want true,nil", attempt, ok, err)
}
}
// The whole visible list is just the editable slot, which is exactly what
// Telegram Desktop sends here.
if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("reorder with nothing collectible = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
// The editable slot is untouched by either call: only collectibles are hidden.
if len(registry.byPeer[peer]) != 1 || !registry.byPeer[peer][0].Active {
t.Fatalf("editable slot after bulk calls = %+v, want it left active", registry.byPeer[peer])
}
// A channel with no rows at all behaves the same way.
delete(registry.byPeer, peer)
if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil {
t.Fatalf("deactivate all on an empty registry = %v,%v, want true,nil", ok, err)
}
// Permission is still checked before the registry is consulted.
stranger := WithUserID(context.Background(), owner.ID+4242)
if ok, err := r.onChannelsDeactivateAllUsernames(stranger, input); ok || err == nil {
t.Fatalf("deactivate all as stranger = %v,%v, want false and a permission failure", ok, err)
}
}
func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) {
registry := newFakeUsernameRegistry()
r, owner, channel := newCollectibleChannelFixture(t, registry)
ctx := WithUserID(context.Background(), owner.ID)
registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}] = []domain.Username{
{Username: "chan_slot", Editable: true, Active: true},
{Username: "chan_nft", Active: true, SortOrder: 0, CollectibleID: 44},
}
chats, err := r.onChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err != nil {
t.Fatalf("get channels: %v", err)
}
out := chats.(*tg.MessagesChats).Chats
if len(out) != 1 {
t.Fatalf("chats = %d, want 1", len(out))
}
vector, ok := out[0].(*tg.Channel).GetUsernames()
if !ok {
t.Fatalf("channel usernames unset, want registry vector")
}
if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_slot" || got[1] != "chan_nft" {
t.Fatalf("channel usernames = %v, want [chan_slot chan_nft]", got)
}
if scalar, ok := out[0].(*tg.Channel).GetUsername(); ok || scalar != "" {
t.Fatalf("scalar channel username = %q (set %v), want absent with collectible vector", scalar, ok)
}
}
func TestChannelsGetChannelsDegradesWithoutRegistry(t *testing.T) {
r, owner, channel := newCollectibleChannelFixture(t, nil)
ctx := WithUserID(context.Background(), owner.ID)
chats, err := r.onChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err != nil {
t.Fatalf("get channels: %v", err)
}
vector, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel).GetUsernames()
if ok || len(vector) != 0 {
t.Fatalf("legacy channel usernames = %+v (set %v), want vector absent", vector, ok)
}
if scalar, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel).GetUsername(); !ok || scalar != "chan_slot" {
t.Fatalf("legacy scalar username = %q (set %v), want chan_slot", scalar, ok)
}
}

View file

@ -637,7 +637,7 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
for _, contact := range res.Contacts {
out.Users = append(out.Users, r.tgUser(contact.User))
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil)
r.applyPeerReadModels(ctx, userID, out.Users, nil)
out.RetryContacts = append(out.RetryContacts, res.RetryContacts...)
for _, contact := range res.Contacts {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
@ -1110,7 +1110,7 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil)
r.applyPeerReadModels(ctx, userID, out.Users, nil)
return out
}

View file

@ -672,8 +672,8 @@ func TestUsernameRPCLifecycle(t *testing.T) {
t.Fatalf("update username: %v", err)
}
self, ok := user.(*tg.User)
if !ok || self.Username != "owner_name" || len(self.Usernames) != 1 || !self.Usernames[0].Active {
t.Fatalf("updated user = %T %+v, want self with active username", user, user)
if !ok || self.Username != "owner_name" || len(self.Usernames) != 0 {
t.Fatalf("updated user = %T %+v, want self with scalar username only", user, user)
}
resolved, err := r.onContactsResolveUsername(reqCtx, &tg.ContactsResolveUsernameRequest{Username: "@OWNER_NAME"})

View file

@ -495,7 +495,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
}
if ch.Username != "" {
out.SetUsername(ch.Username)
out.SetUsernames(tgUsernames(ch.Username))
}
if color := tgPeerColor(ch.Color); color != nil {
out.SetColor(color)

View file

@ -27,7 +27,6 @@ func tgSelfUser(u domain.User) *tg.User {
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
@ -60,7 +59,6 @@ func tgUser(u domain.User) *tg.User {
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
@ -240,6 +238,9 @@ func tgUserStatus(status domain.UserStatus) tg.UserStatusClass {
return &tg.UserStatusRecently{}
}
// tgUsernames builds the vector carried by updateUserName. Ordinary user/channel
// constructors keep using their scalar username until at least one collectible
// is associated with the peer.
func tgUsernames(username string) []tg.Username {
if username == "" {
return nil
@ -247,6 +248,45 @@ func tgUsernames(username string) []tg.Username {
return []tg.Username{{Editable: true, Active: true, Username: username}}
}
// tgUsernamesFromRegistry is the single builder of the full username#b4073647
// vector in stored order (see domain.SortUsernames), with editable/active taken
// from the registry row rather than assumed.
//
// It degrades to tgUsernames(fallback) whenever the registry contributed nothing
// usable, which is what keeps a missing/failing registry service byte-identical
// to the pre-collectible wire shape.
func tgUsernamesFromRegistry(list []domain.Username, fallback string) []tg.Username {
if len(list) == 0 {
return tgUsernames(fallback)
}
sorted := domain.SortUsernames(list)
out := make([]tg.Username, 0, len(sorted))
for _, item := range sorted {
name := domain.NormalizeUsername(item.Username)
if name == "" {
continue
}
out = append(out, tg.Username{
Editable: item.Editable,
Active: item.Active,
Username: name,
})
}
if len(out) == 0 {
return tgUsernames(fallback)
}
return out
}
func hasCollectibleUsername(list []domain.Username) bool {
for _, item := range list {
if item.Collectible() {
return true
}
}
return false
}
func tgContacts(list domain.ContactList) tg.ContactsContactsClass {
out := &tg.ContactsContacts{
Contacts: make([]tg.Contact, 0, len(list.Contacts)),

View file

@ -327,6 +327,27 @@ type BotsService interface {
PutWebViewCustomMethodQuery(ctx context.Context, botUserID, userID int64, method, paramsJSON string) (domain.BotWebViewCustomMethodQuery, error)
}
// ServiceBotCallbacks answers inline-button clicks for the built-in bots that run
// inside this process (@verifybot and friends); app/bots implements it.
//
// It exists because the ordinary callback path cannot serve them: an internal bot
// has no MTProto session to receive updateBotCallbackQuery and no Bot API consumer
// to drain the update queue, so pushing the query at it and waiting could only
// ever end in BOT_RESPONSE_TIMEOUT after the full 25-second window. A bot claimed
// here is answered synchronously instead, by the responder that owns it.
//
// OnCallbackQuery reports handled=false when the bot is not one of the responder's
// own, which the edge treats as invalid callback data. The answer is final:
// nothing is registered in the shared callback registry for it, so no external
// setBotCallbackAnswer can overwrite or spoof it.
//
// A nil Deps.ServiceBotCallbacks keeps the edge behaviour exactly as it was:
// every callback is pushed to the bot's session and waited on.
type ServiceBotCallbacks interface {
HandlesBot(botUserID int64) bool
OnCallbackQuery(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error)
}
// UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。
type UserIdentityService interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
@ -931,6 +952,67 @@ type PremiumPromoService interface {
PremiumPromo(ctx context.Context) (domain.PremiumPromoCatalog, bool, error)
}
// UsernameRegistryService is the collectible (Fragment-style) username registry
// boundary. It owns the full per-peer username list -- the editable slot the
// client owns through account/channels.updateUsername plus every collectible
// asset attached to the peer -- and the purchase record behind a collectible.
//
// The registry is deliberately optional. Every RPC surface that consults it must
// degrade to the legacy single-editable-username behaviour when the field is nil
// or a call fails, because the scalar users.username / channels.username column
// remains the editable-name persistence slot.
type UsernameRegistryService interface {
// PeerUsernames returns one peer's full username list. Order is irrelevant:
// callers project through domain.SortUsernames.
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
// UsernamesBatch is the N+1-free variant used by list projections. Peers with
// no registry row may be omitted from the result map.
UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error)
// ToggleUsername activates/deactivates one collectible username. The bool
// reports whether anything changed; false maps to USERNAME_NOT_MODIFIED.
ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error)
// ReorderUsernames rewrites the active username display order, including the
// editable slot when present (domain.ValidateUsernameReorder).
ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error)
// DeactivateAllUsernames deactivates every collectible username of the peer.
DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error)
// Collectible returns the asset and owner needed by the RPC edge to enforce
// fragment visibility before projecting fragment.collectibleInfo.
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
}
// BotVerificationService is the third-party bot verification boundary
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its
// own icon and description, which official clients render as a badge distinct from
// the operator-granted checkmark.
//
// It is the single source for every surface that projects the feature --
// user.bot_verification_icon, channel.bot_verification_icon,
// userFull.bot_verification, channelFull.bot_verification,
// chatInvite.bot_verification and botInfo.verifier_settings -- so no two responses
// can disagree about which mark a peer carries.
//
// Optional like UsernameRegistryService: a nil field, or
// any read error, must leave every flag unset and bots.setCustomVerification
// answering BOT_VERIFIER_FORBIDDEN, which is exactly the pre-feature wire shape.
type BotVerificationService interface {
// PeerVerification returns the peer's single mark, or
// domain.ErrCustomVerificationNotFound.
PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error)
// PeerVerificationBatch is the N+1-free variant used by the response-boundary
// overlay. Peers without a mark may be omitted from the result map.
PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error)
// VerifierSettings reads one bot's verifier status, or
// domain.ErrVerifierNotFound.
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
// VerifierSettingsBatch resolves several bots at once for the botInfo
// projection; bots without verifier status may be omitted.
VerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error)
// SetCustomVerification applies bots.setCustomVerification. changed controls
// update fan-out only; the RPC returns Bool true for an idempotent success.
SetCustomVerification(ctx context.Context, req domain.SetCustomVerificationRequest) (changed bool, err error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件auth.go / users.go / updates.go
type Deps struct {
Auth AuthService
@ -949,6 +1031,8 @@ type Deps struct {
EphemeralPush store.EphemeralPushBroker
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
@ -965,6 +1049,7 @@ type Deps struct {
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService

350
internal/rpc/fragment.go Normal file
View file

@ -0,0 +1,350 @@
package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
// Collectible (Fragment-style) usernames on the protocol edge.
//
// This file owns three things:
//
// - fragment.getCollectibleInfo, the purchase-record lookup a client opens from
// the "this username was bought on Fragment" badge;
// - the projection overlay that turns the legacy single-username vector into
// the full username#b4073647 list (editable slot + collectibles);
// - the shared toggle/reorder/deactivate plumbing behind
// account.*, channels.* and bots.* username management.
//
// Every entry point degrades: with Deps.Usernames nil (or on any registry error)
// the wire shape is exactly what it was before collectibles existed.
// registerFragment 注册 fragment.* RPC handler。
func (r *Router) registerFragment(d *tlprofile.Dispatcher) {
registerRPC[*tg.FragmentGetCollectibleInfoRequest](d, tlprofile.SemanticMethodFragmentGetCollectibleInfo, func(ctx context.Context, layerRequest *tg.FragmentGetCollectibleInfoRequest) (any, error) {
return r.onFragmentGetCollectibleInfo(ctx, layerRequest)
})
}
// onFragmentGetCollectibleInfo answers fragment.getCollectibleInfo.
//
// Only inputCollectibleUsername is answerable here: this server has no
// collectible-phone registry. The method exposes only assets visible to the
// caller: their own inactive collectible, a collectible attached to a channel
// they own, or anybody's active collectible.
func (r *Router) onFragmentGetCollectibleInfo(ctx context.Context, req *tg.FragmentGetCollectibleInfoRequest) (*tg.FragmentCollectibleInfo, error) {
if req == nil {
return nil, collectibleInvalidErr()
}
// An authenticated caller is required, matching every other profile lookup.
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
switch collectible := req.Collectible.(type) {
case *tg.InputCollectibleUsername:
if collectible == nil {
return nil, collectibleInvalidErr()
}
return r.collectibleUsernameInfo(ctx, userID, collectible.Username)
case *tg.InputCollectiblePhone:
if collectible == nil || strings.TrimSpace(collectible.Phone) == "" {
return nil, collectibleInvalidErr()
}
return nil, collectibleNotFoundErr()
default:
return nil, collectibleInvalidErr()
}
}
func (r *Router) collectibleUsernameInfo(ctx context.Context, userID int64, username string) (*tg.FragmentCollectibleInfo, error) {
name := domain.NormalizeUsername(username)
// Syntax first: a name that cannot be a collectible is COLLECTIBLE_INVALID, and
// rejecting it here keeps malformed input off the registry.
if !domain.ValidCollectibleUsername(name) {
return nil, collectibleInvalidErr()
}
if r.deps.Usernames == nil {
return nil, collectibleNotFoundErr()
}
asset, err := r.deps.Usernames.Collectible(ctx, name)
if err != nil {
return nil, collectibleInfoErr(err)
}
if !asset.Owned() {
return nil, collectibleNotFoundErr()
}
visible, err := r.collectibleVisibleTo(ctx, userID, asset)
if err != nil {
return nil, internalErr()
}
if !visible {
// Do not reveal that an inactive collectible is associated with another
// peer. The official surface intentionally collapses that state to not found.
return nil, collectibleNotFoundErr()
}
info := asset.Info()
out := &tg.FragmentCollectibleInfo{
PurchaseDate: info.PurchaseDate,
Currency: info.Currency,
Amount: info.Amount,
CryptoCurrency: info.CryptoCurrency,
CryptoAmount: info.CryptoAmount,
URL: info.URL,
}
return out, nil
}
func (r *Router) collectibleVisibleTo(ctx context.Context, userID int64, asset domain.CollectibleUsername) (bool, error) {
if asset.Owner.Type == domain.PeerTypeUser && asset.Owner.ID == userID {
return true, nil
}
list, err := r.deps.Usernames.PeerUsernames(ctx, asset.Owner)
if err != nil {
return false, err
}
for _, item := range list {
if item.CollectibleID == asset.ID && item.Active {
return true, nil
}
}
if asset.Owner.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
return false, nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, asset.Owner.ID)
if err != nil {
// Inaccessible channels are indistinguishable from an inactive asset owned
// by somebody else.
return false, nil
}
return view.Self.Role == domain.ChannelRoleCreator, nil
}
func collectibleInvalidErr() error { return tgerr400("COLLECTIBLE_INVALID") }
func collectibleNotFoundErr() error { return tgerr400("COLLECTIBLE_NOT_FOUND") }
// collectibleInfoErr maps registry lookup failures onto TL.
func collectibleInfoErr(err error) error {
switch {
case errors.Is(err, domain.ErrCollectibleUsernameNotFound),
errors.Is(err, domain.ErrUsernameNotOccupied),
errors.Is(err, domain.ErrCollectibleUsernameBurned),
errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
return collectibleNotFoundErr()
case errors.Is(err, domain.ErrUsernameNotCollectible),
errors.Is(err, domain.ErrUsernameInvalid):
return collectibleInvalidErr()
default:
return internalErr()
}
}
// collectibleUsernameErr maps registry mutation failures onto TL.
//
// domain.ErrUsernameNotCollectible and domain.ErrUsernameOrderInvalid both mean
// "the client asked for something the collectible slots cannot express" -- moving
// or deactivating the editable slot, or an order that is not a permutation of the
// peer's collectibles -- so both are USERNAME_INVALID.
func collectibleUsernameErr(err error) error {
switch {
case errors.Is(err, domain.ErrUsernameNotCollectible),
errors.Is(err, domain.ErrUsernameOrderInvalid),
errors.Is(err, domain.ErrUsernameNotEditable),
errors.Is(err, domain.ErrUsernameInvalid):
return usernameInvalidErr()
case errors.Is(err, domain.ErrUsernameNotOccupied),
errors.Is(err, domain.ErrCollectibleUsernameNotFound),
errors.Is(err, domain.ErrCollectibleUsernameNotOwned),
errors.Is(err, domain.ErrCollectibleUsernameBurned):
return usernameNotOccupiedErr()
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
return limitInvalidErr()
default:
return internalErr()
}
}
// toggleRegistryUsername is the shared body of account/channels/bots
// .toggleUsername. Callers do the permission check first.
func (r *Router) toggleRegistryUsername(ctx context.Context, peer domain.Peer, username string, active bool) error {
name := domain.NormalizeUsername(username)
if !domain.ValidCollectibleUsername(name) {
return usernameInvalidErr()
}
changed, err := r.deps.Usernames.ToggleUsername(ctx, peer, name, active)
if err != nil {
return collectibleUsernameErr(err)
}
if !changed {
return usernameNotModifiedErr()
}
r.invalidateRegistryProjection(peer)
return nil
}
// reorderRegistryUsernames is the shared body of account/channels/bots
// .reorderUsernames.
func (r *Router) reorderRegistryUsernames(ctx context.Context, peer domain.Peer, order []string) error {
normalized := make([]string, 0, len(order))
for _, name := range order {
normalized = append(normalized, domain.NormalizeUsername(name))
}
changed, err := r.deps.Usernames.ReorderUsernames(ctx, peer, normalized)
if err != nil {
if errors.Is(err, domain.ErrUsernameOrderInvalid) {
return tgerr400("ORDER_INVALID")
}
return collectibleUsernameErr(err)
}
if !changed {
return usernameNotModifiedErr()
}
r.invalidateRegistryProjection(peer)
return nil
}
// deactivateAllRegistryUsernames is the shared body of
// channels.deactivateAllUsernames: it hides every collectible username of a peer.
//
// Deactivating an empty set is success, not USERNAME_NOT_MODIFIED: Telegram
// Desktop calls channels.deactivateAllUsernames as a step of its "set the
// username" flow, so a peer that has no collectible usernames yet -- the common
// case for a freshly created channel -- would abort that flow on a 400. The
// no-op stub this replaced also answered true, and clients depend on it.
func (r *Router) deactivateAllRegistryUsernames(ctx context.Context, peer domain.Peer) error {
changed, err := r.deps.Usernames.DeactivateAllUsernames(ctx, peer)
if err != nil {
return collectibleUsernameErr(err)
}
if !changed {
return nil
}
r.invalidateRegistryProjection(peer)
return nil
}
// invalidateRegistryProjection drops the cached user/channel projections that
// embed the username vector, so the next getFullUser / getFullChannel rebuilds it.
func (r *Router) invalidateRegistryProjection(peer domain.Peer) {
switch peer.Type {
case domain.PeerTypeUser:
r.invalidateRPCProjectionForUser(peer.ID)
case domain.PeerTypeChannel:
r.invalidateRPCProjectionForChannel(peer.ID)
}
}
// applyUsernamesToPeerObjects overlays the registry onto already-projected user
// and channel objects. Per the Fragment contract, the scalar username is cleared
// and the vector is set only when a collectible is associated with the peer.
//
// It mirrors applyStoryMaxIDsToPeerObjects: one batched read-model call per
// response instead of a per-peer query, and a silent no-op whenever the read
// model is unavailable. Overlaying after projection is what keeps the ~90
// pure tgUser/tgChannel call sites untouched -- they keep emitting the legacy
// scalar, and this pass upgrades it wherever a Router-level entry point runs.
func (r *Router) applyUsernamesToPeerObjects(ctx context.Context, users []tg.UserClass, chats []tg.ChatClass) {
if r.deps.Usernames == 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.usernameRegistryMap(ctx, peers)
if len(byPeer) == 0 {
return
}
applyUsernamesFromRegistry(users, chats, byPeer)
}
// applyUsernamesFromRegistry applies a previously loaded registry snapshot.
// Notification fan-out uses this form so one peer-wide read does not become one
// database query per online viewer.
func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPeer map[domain.Peer][]domain.Username) {
if len(byPeer) == 0 {
return
}
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
if vector := tgUsernamesFromRegistry(list, u.Username); len(vector) > 0 {
u.Flags.Unset(3)
u.Username = ""
u.SetUsernames(vector)
}
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
// ch.Username is the flagged scalar; GetUsername reports the empty string
// when unset, which is exactly the fallback tgUsernamesFromRegistry wants.
scalar, _ := ch.GetUsername()
if vector := tgUsernamesFromRegistry(list, scalar); len(vector) > 0 {
ch.Flags.Unset(6)
ch.Username = ""
ch.SetUsernames(vector)
}
}
}
// usernameRegistryMap loads the registry for the given peers. A single peer goes
// through PeerUsernames so a one-object projection does not pay for a batch
// round trip; anything larger goes through UsernamesBatch (no N+1). Any error
// yields an empty map, which the caller treats as "keep the legacy scalar".
func (r *Router) usernameRegistryMap(ctx context.Context, peers []domain.Peer) map[domain.Peer][]domain.Username {
if r.deps.Usernames == nil || len(peers) == 0 {
return nil
}
if len(peers) == 1 {
list, err := r.deps.Usernames.PeerUsernames(ctx, peers[0])
if err != nil || len(list) == 0 {
return nil
}
return map[domain.Peer][]domain.Username{peers[0]: list}
}
byPeer, err := r.deps.Usernames.UsernamesBatch(ctx, peers)
if err != nil {
return nil
}
return byPeer
}

View file

@ -422,7 +422,7 @@ func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUse
selfUser = r.tgSelfUser(target)
}
users := []tg.UserClass{selfUser}
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, users, nil)
r.applyPeerReadModels(ctx, currentUserID, users, nil)
countOffset := offset
if countOffset < 0 {
countOffset = 0

View file

@ -108,6 +108,8 @@ func (r *Router) NotifyUserModerationFlagsChanged(ctx context.Context, u domain.
pushCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
botVerificationIcon := r.peerBotVerificationIcon(pushCtx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
usernames := r.usernameRegistryMap(pushCtx, []domain.Peer{{Type: domain.PeerTypeUser, ID: u.ID}})
seen := make(map[int64]struct{}, len(audience))
for _, viewerUserID := range audience {
if viewerUserID == 0 {
@ -128,9 +130,18 @@ func (r *Router) NotifyUserModerationFlagsChanged(ctx context.Context, u domain.
zap.Error(err))
continue
}
projected := tgUsersForViewer(viewerUserID, users)
// The pushed peer object has to match what users.getUsers would answer, or the
// client refreshes the peer straight back into the stale shape. The third-party
// verification icon (user#b1b8cc83 bot_verification_icon:flags2.14) lives in a
// read model rather than on domain.User, so it is stamped on here -- from the
// single read taken before the loop, since it is the same peer for every
// recipient. Zero leaves flags2.14 unset, which is the pre-feature shape.
applyBotVerificationIconToUsers(projected, u.ID, botVerificationIcon)
applyUsernamesFromRegistry(projected, nil, usernames)
r.pushUserUpdates(pushCtx, viewerUserID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}},
Users: tgUsersForViewer(viewerUserID, users),
Users: projected,
Date: int(r.clock.Now().Unix()),
Seq: 0,
})

View file

@ -567,6 +567,11 @@ func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.User
return res
}
// tgUser / tgSelfUser deliberately do NOT overlay the username registry or the
// bot verification icon: they are called inside per-id loops (users.getUsers), and a
// per-object read there would be exactly the N+1 the batch overlay exists to avoid.
// Single-object handlers pick both up from applyPeerReadModels at the response
// boundary.
func (r *Router) tgUser(u domain.User) *tg.User {
return r.withBotProfileFlags(context.Background(), tgUser(r.withUserPresence(u)))
}
@ -578,6 +583,8 @@ func (r *Router) tgSelfUser(u domain.User) *tg.User {
func (r *Router) tgUsers(users []domain.User) []tg.UserClass {
out := tgUsers(r.withUsersPresence(users))
r.withBotProfileFlagsForUsers(context.Background(), out)
r.applyUsernamesToPeerObjects(context.Background(), out, nil)
r.applyBotVerificationIconsToPeerObjects(context.Background(), out, nil)
return out
}
@ -586,6 +593,8 @@ func (r *Router) tgUsers(users []domain.User) []tg.UserClass {
func (r *Router) tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass {
out := tgUsersForViewer(viewerUserID, r.withUsersPresence(users))
r.withBotProfileFlagsForUsers(context.Background(), out)
r.applyUsernamesToPeerObjects(context.Background(), out, nil)
r.applyBotVerificationIconsToPeerObjects(context.Background(), out, nil)
return out
}

View file

@ -289,6 +289,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerPremium(d)
r.registerAiCompose(d)
r.registerBots(d)
r.registerFragment(d)
r.registerEphemeral(d)
r.dispatcher = d

View file

@ -295,7 +295,7 @@ func (r *Router) tgStatsPublicForwards(ctx context.Context, viewerUserID int64,
if list.NextOffset != "" {
out.SetNextOffset(list.NextOffset)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}

View file

@ -13,7 +13,7 @@ func (r *Router) tgStoriesAllStories(ctx context.Context, viewerUserID int64, li
list = r.withStoryListPeerObjects(ctx, viewerUserID, list)
out := tgStoriesAllStories(viewerUserID, list)
if stories, ok := out.(*tg.StoriesAllStories); ok {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, stories.Users, stories.Chats)
r.applyPeerReadModels(ctx, viewerUserID, stories.Users, stories.Chats)
}
return out
}
@ -21,14 +21,14 @@ func (r *Router) tgStoriesAllStories(ctx context.Context, viewerUserID int64, li
func (r *Router) tgStoriesStories(ctx context.Context, viewerUserID int64, list domain.StoryList) *tg.StoriesStories {
list = r.withStoryListPeerObjects(ctx, viewerUserID, list)
out := tgStoriesStories(viewerUserID, list)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) tgStoriesPeerStories(ctx context.Context, viewerUserID int64, peerStories domain.PeerStories) *tg.StoriesPeerStories {
peerStories = r.withPeerStoriesPeerObjects(ctx, viewerUserID, peerStories)
out := tgStoriesPeerStories(viewerUserID, peerStories)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
@ -40,7 +40,7 @@ func (r *Router) tgStoryViewsList(ctx context.Context, viewerUserID int64, list
if len(peerChannels) > 0 {
out.Chats = appendUniqueTGChats(out.Chats, tgChannels(viewerUserID, peerChannels)...)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
@ -52,16 +52,16 @@ func (r *Router) tgStoryReactionsList(ctx context.Context, viewerUserID int64, l
if len(peerChannels) > 0 {
out.Chats = appendUniqueTGChats(out.Chats, tgChannels(viewerUserID, peerChannels)...)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) applyStoryMaxIDsToChats(ctx context.Context, viewerUserID int64, out tg.MessagesChatsClass) tg.MessagesChatsClass {
switch v := out.(type) {
case *tg.MessagesChats:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, nil, v.Chats)
case *tg.MessagesChatsSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, nil, v.Chats)
}
return out
}
@ -71,9 +71,9 @@ func (r *Router) tgMessagesDialogs(ctx context.Context, viewerUserID int64, list
out := tgMessagesDialogs(viewerUserID, list)
switch v := out.(type) {
case *tg.MessagesDialogs:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.MessagesDialogsSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
}
return out
}
@ -81,33 +81,33 @@ func (r *Router) tgMessagesDialogs(ctx context.Context, viewerUserID int64, list
func (r *Router) tgPeerDialogs(ctx context.Context, viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs {
list = r.withDialogNotifySettings(ctx, viewerUserID, list)
out := tgPeerDialogs(viewerUserID, list, st)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) tgContacts(ctx context.Context, viewerUserID int64, list domain.ContactList) tg.ContactsContactsClass {
out := tgContacts(list)
if contacts, ok := out.(*tg.ContactsContacts); ok {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, contacts.Users, nil)
r.applyPeerReadModels(ctx, viewerUserID, contacts.Users, nil)
}
return out
}
func (r *Router) tgContactsFound(ctx context.Context, viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound {
out := tgContactsFound(viewerUserID, res)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) tgResolvedUserPeerWithStories(ctx context.Context, viewerUserID int64, u domain.User) *tg.ContactsResolvedPeer {
out := r.tgResolvedUserPeer(viewerUserID, u)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, nil)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, nil)
return out
}
func (r *Router) tgResolvedChannelPeerWithStories(ctx context.Context, viewerUserID int64, view domain.ChannelView) *tg.ContactsResolvedPeer {
out := tgResolvedChannelPeer(viewerUserID, view)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, nil, out.Chats)
return out
}
@ -147,27 +147,27 @@ func (r *Router) tgMessagesDiscussionMessage(ctx context.Context, viewerUserID i
// 用带 presence + self 标志的投影覆盖裸 tgUsers防止 viewer 自己以 self=false
// 进入 UsersAndroid putUsers 会覆盖 currentUser
out.Users = r.tgUsersForViewer(viewerUserID, discussion.Users)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) applyStoryMaxIDsToForumTopics(ctx context.Context, viewerUserID int64, out *tg.MessagesForumTopics) *tg.MessagesForumTopics {
if out != nil {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
}
return out
}
func (r *Router) applyStoryMaxIDsToMessageViews(ctx context.Context, viewerUserID int64, out *tg.MessagesMessageViews) *tg.MessagesMessageViews {
if out != nil {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
}
return out
}
func (r *Router) applyStoryMaxIDsToMessageReactionsList(ctx context.Context, viewerUserID int64, out *tg.MessagesMessageReactionsList) *tg.MessagesMessageReactionsList {
if out != nil {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats)
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
}
return out
}
@ -181,11 +181,11 @@ func (r *Router) tgGlobalSearchMessages(ctx context.Context, viewerUserID int64,
func (r *Router) applyStoryMaxIDsToMessages(ctx context.Context, viewerUserID int64, out tg.MessagesMessagesClass) {
switch v := out.(type) {
case *tg.MessagesMessages:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.MessagesMessagesSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.MessagesChannelMessages:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
}
}
@ -206,9 +206,9 @@ func (r *Router) tgUpdatesDifference(ctx context.Context, viewerUserID int64, di
out := tgUpdatesDifference(viewerUserID, diff)
switch v := out.(type) {
case *tg.UpdatesDifference:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
case *tg.UpdatesDifferenceSlice:
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats)
r.applyPeerReadModels(ctx, viewerUserID, v.Users, v.Chats)
}
return out
}
@ -224,7 +224,7 @@ func (r *Router) withStoryUpdatePeerObjects(ctx context.Context, viewerUserID in
if len(channels) > 0 {
updates.Chats = appendUniqueTGChats(updates.Chats, tgChannels(viewerUserID, channels)...)
}
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, updates.Users, updates.Chats)
r.applyPeerReadModels(ctx, viewerUserID, updates.Users, updates.Chats)
return updates
}
@ -271,7 +271,7 @@ func (r *Router) appendStoryPrivacyUsers(ctx context.Context, viewerUserID int64
return
}
updates.Users = appendUniqueTGUsers(updates.Users, r.tgUsersForViewer(viewerUserID, users)...)
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, updates.Users, updates.Chats)
r.applyPeerReadModels(ctx, viewerUserID, updates.Users, updates.Chats)
}
func storyViewUserIDs(views []domain.StoryView) []int64 {
@ -497,6 +497,19 @@ func appendUniqueTGChats(base []tg.ChatClass, extra ...tg.ChatClass) []tg.ChatCl
return out
}
// applyPeerReadModels is the response-boundary overlay pass for peer objects that
// have already been projected. Every read model it drives is batched over the
// whole user/chat set, so a handler pays one call per read model per response
// rather than one per peer.
//
// It is the single hook every handler uses; adding a read model here reaches all
// of them at once.
func (r *Router) applyPeerReadModels(ctx context.Context, viewerUserID int64, users []tg.UserClass, chats []tg.ChatClass) {
r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, users, chats)
r.applyUsernamesToPeerObjects(ctx, users, chats)
r.applyBotVerificationIconsToPeerObjects(ctx, users, chats)
}
func (r *Router) applyStoryMaxIDsToPeerObjects(ctx context.Context, viewerUserID int64, users []tg.UserClass, chats []tg.ChatClass) {
if r.deps.Stories == nil || viewerUserID == 0 || len(users)+len(chats) == 0 {
return

View file

@ -0,0 +1,64 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// NotifyPeerUsernamesChanged is the domain-only edge hook invoked after a
// collectible username registry mutation commits. It invalidates the cached
// peer projection and pushes the ordinary non-PTS updateUser/updateChannel
// refresh to online viewers. The shared projection paths preload the registry
// once, so fan-out cannot turn the change into an N+1 query.
func (r *Router) NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer usernames changed: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserUsernamesChanged(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelUsernamesChanged(ctx, peer.ID)
default:
return fmt.Errorf("notify peer usernames changed: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
func (r *Router) notifyUserUsernamesChanged(ctx context.Context, userID int64) error {
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer usernames changed: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer usernames changed: user %d not found", userID)
}
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
func (r *Router) notifyChannelUsernamesChanged(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer usernames changed: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer usernames changed: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer usernames changed: channel %d not found", channelID)
}
return r.NotifyChannelChanged(ctx, channel)
}

View file

@ -0,0 +1,94 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/domain"
)
var _ usernamesapp.PeerUsernameNotifier = (*Router)(nil)
func TestNotifyPeerUsernamesChangedUserPushesPreloadedVector(t *testing.T) {
const (
targetID = int64(2002)
viewerID = int64(1001)
)
users := &verifiedNotifyUsers{
user: domain.User{ID: targetID, FirstName: "Owner", Username: "owner_slot"},
found: true,
audience: []int64{targetID, viewerID},
}
registry := newFakeUsernameRegistry()
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: targetID}] = []domain.Username{
{Username: "owner_slot", Editable: true, Active: true},
{Username: "nft", Active: true, CollectibleID: 7},
}
sessions := &captureSessions{onlineUserIDs: []int64{targetID, viewerID}}
r := New(Config{}, Deps{Users: users, Usernames: registry, Sessions: sessions}, zap.NewNop(), clock.System)
if err := r.NotifyPeerUsernamesChanged(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: targetID,
}); err != nil {
t.Fatalf("notify user usernames: %v", err)
}
if registry.peerCalls != 1 || registry.batchCalls != 0 {
t.Fatalf("registry reads = peer %d / batch %d, want one peer-wide read", registry.peerCalls, registry.batchCalls)
}
updates := sessions.lastUserPush().(*tg.Updates)
user := updates.Users[0].(*tg.User)
if scalar, ok := user.GetUsername(); ok || scalar != "" {
t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok)
}
vector, ok := user.GetUsernames()
if !ok || len(vector) != 2 || vector[1].Username != "nft" {
t.Fatalf("pushed username vector = %+v (set %v)", vector, ok)
}
}
func TestNotifyPeerUsernamesChangedChannelPushesPreloadedVector(t *testing.T) {
const (
channelID = int64(4004)
ownerID = int64(3003)
memberID = int64(3004)
)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
Title: "Collectible", Username: "channel_slot", Broadcast: true,
}}
registry := newFakeUsernameRegistry()
registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = []domain.Username{
{Username: "channel_slot", Editable: true, Active: true},
{Username: "collectible", Active: true, CollectibleID: 9},
}
sessions := &captureSessions{
onlineUserIDs: []int64{ownerID, memberID},
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
}
r := New(Config{}, Deps{
Channels: channels, Usernames: registry, Sessions: sessions,
}, zap.NewNop(), clock.System)
if err := r.NotifyPeerUsernamesChanged(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify channel usernames: %v", err)
}
if registry.peerCalls != 1 || registry.batchCalls != 0 {
t.Fatalf("registry reads = peer %d / batch %d, want one peer-wide read", registry.peerCalls, registry.batchCalls)
}
updates := sessions.lastUserPush().(*tg.Updates)
channel := updates.Chats[0].(*tg.Channel)
if scalar, ok := channel.GetUsername(); ok || scalar != "" {
t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok)
}
vector, ok := channel.GetUsernames()
if !ok || len(vector) != 2 || vector[1].Username != "collectible" {
t.Fatalf("pushed username vector = %+v (set %v)", vector, ok)
}
}

View file

@ -125,7 +125,7 @@ func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) (
}
out = append(out, r.tgUser(u))
}
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, out, nil)
r.applyPeerReadModels(ctx, currentUserID, out, nil)
return out, nil
}
@ -159,7 +159,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
return nil, err
}
applyPrivateContactRestrictionToUser(user, contactRestriction)
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
r.applyPeerReadModels(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
if !applyContactNoteToUserFull(u, &full) {
@ -170,6 +170,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
r.applyBotVerificationToUserFull(ctx, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
@ -191,6 +192,10 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
// Deliberately after StoreIfEpoch, like applyPersonalChannelToUserFull: the mark
// is not baked into the per-(viewer,target) cache, so a revoked badge is gone on
// the next response instead of lingering for the cache TTL.
r.applyBotVerificationToUserFull(ctx, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
@ -722,14 +727,16 @@ func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUser
// TDesktop 的 botInfo.inited 永不置位,每次开聊/输 "/" 都会重拉 getFullUser
// user_id 必填且必须等于该 bot 的 id不匹配会被客户端整体静默忽略。
func (r *Router) tgBotInfo(ctx context.Context, u domain.User) tg.BotInfo {
if r.deps.Bots == nil {
return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
info := tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
if r.deps.Bots != nil {
if profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err == nil && found {
info = tgBotInfoFromProfile(u.ID, profile, true)
}
}
profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID)
if err != nil || !found {
return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
}
return tgBotInfoFromProfile(u.ID, profile, true)
// botInfo#4d8a0299 verifier_settings:flags.9 -- present only for a bot that is
// itself an enabled verifier, and independent of the bot profile above.
r.applyVerifierSettingsToOneBotInfo(ctx, &info, u.ID)
return info
}
type botProfileBatchResolver interface {
@ -755,10 +762,18 @@ func (r *Router) tgBotInfos(ctx context.Context, userIDs []int64) []tg.BotInfo {
}
}
}
// One verifier-settings query for the whole bot list, next to the profile batch
// above: channelFull.bot_info must not turn into an N+1 just to carry
// verifier_settings:flags.9.
verifiers := r.verifierSettingsBatch(ctx, ids)
out := make([]tg.BotInfo, 0, len(userIDs))
for _, id := range userIDs {
profile, found := profiles[id]
out = append(out, tgBotInfoFromProfile(id, profile, found))
info := tgBotInfoFromProfile(id, profile, found)
if settings, ok := verifiers[id]; ok {
applyVerifierSettingsToBotInfo(&info, id, settings)
}
out = append(out, info)
}
return out
}

View file

@ -0,0 +1,117 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// verificationUserDirectory is the optional viewer-independent account reader.
// users.Service satisfies it; it is preferred over UsersService.ByID because
// "does this peer exist" must not depend on a viewer projection.
type verificationUserDirectory interface {
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
}
// verificationChannelDirectory is the optional non-personalized channel base-row
// reader. channels.Service satisfies it (see the same assertion in communities.go);
// the badge hook has no viewer of its own, so it cannot use GetChannel.
type verificationChannelDirectory interface {
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
}
// NotifyPeerVerified is the protocol-edge hook the official verification service
// invokes after an approve/reject/revoke decision has already committed. It makes
// the new user#b1b8cc83 verified:flags.17 / channel#d49f34c6 verified:flags.7 bit
// observable without waiting for a cache TTL:
//
// - the cached peer projections for the target are dropped, so the next
// users.getFullUser / channels.getFullChannel rebuilds from the committed row;
// - online clients that already know the peer are pushed the ordinary, non-PTS
// refresh update (updateUser / updateChannel) together with the re-projected
// peer object, which is what flips the badge in an official client live.
//
// It deliberately reuses the paths the scam/fake moderation flags already use
// rather than inventing a verification-specific update: the badge is one more
// boolean on the same peer record, and a second mechanism could only drift.
//
// Offline sessions are not pushed to and do not need to be: verified is part of
// the peer's base read model, whose version is bumped by the users/channels
// triggers in 0001_init, so updates.getDifference and any later getFullUser /
// getUsers answer already carries the new flag.
//
// A push failure never invalidates the committed decision, so the caller logs and
// swallows the returned error; this method therefore reports problems instead of
// panicking, and is safe on a nil receiver.
func (r *Router) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer verified: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserVerified(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelVerified(ctx, peer.ID)
default:
return fmt.Errorf("notify peer verified: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
// notifyUserVerified covers ordinary accounts and bots alike: a verified bot is a
// user#b1b8cc83 with verified:flags.17, so it takes the same audience-wide
// updateUser fan-out the moderation flags use (owner plus every online account
// that already sees the peer).
func (r *Router) notifyUserVerified(ctx context.Context, userID int64) error {
// Invalidate first and unconditionally: a decided application whose projection
// still says "not verified" would keep serving the stale badge state even if the
// push below cannot run.
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer verified: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer verified: user %d not found", userID)
}
// NotifyUserModerationFlagsChanged re-projects the peer per recipient, so the
// snapshot handed in only carries identity; the pushed tg.User is always built
// from a fresh read.
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
func (r *Router) verificationUser(ctx context.Context, userID int64) (domain.User, bool, error) {
if directory, ok := r.deps.Users.(verificationUserDirectory); ok {
return directory.AdminUser(ctx, userID)
}
return r.deps.Users.ByID(ctx, userID, userID)
}
// notifyChannelVerified reuses the channel state-mutation path, which invalidates
// the channel projections (plus a linked monoforum's) and pushes updateChannel
// with the refreshed chat object to the channel's members.
func (r *Router) notifyChannelVerified(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer verified: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer verified: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer verified: channel %d not found", channelID)
}
// Same hook the admin panel uses for any other channel base fact.
return r.NotifyChannelChanged(ctx, channel)
}

View file

@ -0,0 +1,319 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
)
// The process wires the badge hook through a dynamic type assertion on
// *rpc.Router, so a signature drift would silently fall back to the
// invalidation-only notifier instead of breaking the build. Assert the port here
// so it breaks the build instead.
var _ verificationapp.PeerNotifier = (*Router)(nil)
// verifiedNotifyUsers is the authoritative account reader plus the moderation
// audience port, i.e. exactly the two capabilities the badge push relies on.
type verifiedNotifyUsers struct {
UsersService
user domain.User
found bool
adminCalls int
audience []int64
viewers []int64
}
func (s *verifiedNotifyUsers) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
s.adminCalls++
if !s.found || s.user.ID != userID {
return domain.User{}, false, nil
}
return s.user, true, nil
}
func (s *verifiedNotifyUsers) ByIDs(_ context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
s.viewers = append(s.viewers, viewerUserID)
if !s.found || len(ids) == 0 {
return nil, nil
}
return []domain.User{s.user}, nil
}
func (s *verifiedNotifyUsers) ModerationFlagAudience(_ context.Context, _ int64, _ int) ([]int64, error) {
return append([]int64(nil), s.audience...), nil
}
// verifiedNotifyChannels exposes the viewer-independent base row the hook needs
// plus the membership filter the channel fan-out uses.
type verifiedNotifyChannels struct {
ChannelsService
channel domain.Channel
err error
calls int
}
func (s *verifiedNotifyChannels) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
s.calls++
if s.err != nil {
return domain.Channel{}, s.err
}
if s.channel.ID != channelID {
return domain.Channel{}, nil
}
return s.channel, nil
}
func (s *verifiedNotifyChannels) FilterActiveMemberIDs(_ context.Context, _ int64, userIDs []int64) ([]int64, error) {
return append([]int64(nil), userIDs...), nil
}
// channelsWithoutDirectory models a channels adapter that does not expose the
// non-personalized base-row reader.
type channelsWithoutDirectory struct{ ChannelsService }
func seedUserFullProjection(t *testing.T, r *Router, viewerUserID, targetUserID int64) {
t.Helper()
epoch := r.userFullProjectionCache.LoadEpoch()
r.userFullProjectionCache.StoreIfEpoch(viewerUserID, targetUserID, tg.UserFull{ID: targetUserID}, epoch)
if _, ok := r.userFullProjectionCache.Lookup(viewerUserID, targetUserID); !ok {
t.Fatalf("seed userFull projection for viewer %d target %d", viewerUserID, targetUserID)
}
}
func seedChannelFullProjection(t *testing.T, r *Router, viewerUserID, channelID int64) {
t.Helper()
epoch := r.channelFullProjectionCache.LoadEpoch()
r.channelFullProjectionCache.StoreIfEpoch(viewerUserID, channelID, channelFullProjection{
accessHash: 1,
full: tg.ChannelFull{ID: channelID},
}, epoch)
if _, ok := r.channelFullProjectionCache.Lookup(viewerUserID, channelID); !ok {
t.Fatalf("seed channelFull projection for viewer %d channel %d", viewerUserID, channelID)
}
}
// TestNotifyPeerVerifiedUserInvalidatesAndPushesUpdateUser is the bot/user half of
// the main scenario: an approved bot must reach every online account that already
// sees it, through the same non-PTS updateUser shape the scam/fake flags use.
func TestNotifyPeerVerifiedUserInvalidatesAndPushesUpdateUser(t *testing.T) {
const (
botID = int64(1250000012)
onlineViewerID = int64(1001)
offlineViewerID = int64(3003)
)
users := &verifiedNotifyUsers{
user: domain.User{ID: botID, FirstName: "Shop", Bot: true, Verified: true},
found: true,
audience: []int64{botID, onlineViewerID, offlineViewerID},
}
sessions := &captureSessions{onlineUserIDs: []int64{botID, onlineViewerID}}
r := New(Config{}, Deps{Users: users, Sessions: sessions}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, onlineViewerID, botID)
seedUserFullProjection(t, r, botID, botID)
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: botID,
}); err != nil {
t.Fatalf("notify peer verified: %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(onlineViewerID, botID); ok {
t.Fatal("viewer userFull projection survived the badge change")
}
if _, ok := r.userFullProjectionCache.Lookup(botID, botID); ok {
t.Fatal("target userFull projection survived the badge change")
}
if users.adminCalls != 1 {
t.Fatalf("authoritative account reads = %d", users.adminCalls)
}
// Offline audience members are skipped; they converge through the bumped
// user_base read model on their next getDifference / getFullUser.
pushed := sessions.pushedUserIDs()
if len(pushed) != 2 || pushed[0] != botID || pushed[1] != onlineViewerID {
t.Fatalf("pushed user ids = %v", pushed)
}
if len(users.viewers) != 2 || users.viewers[0] != botID || users.viewers[1] != onlineViewerID {
t.Fatalf("re-projected viewers = %v", users.viewers)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateUser)
if !ok || refresh.UserID != botID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Users) != 1 {
t.Fatalf("users = %+v", updates.Users)
}
user, ok := updates.Users[0].(*tg.User)
if !ok || user.ID != botID || !user.Verified {
t.Fatalf("user = %T %+v", updates.Users[0], updates.Users[0])
}
if user.Scam || user.Fake {
t.Fatalf("badge push leaked moderation flags: %+v", user)
}
}
// TestNotifyPeerVerifiedChannelInvalidatesAndPushesUpdateChannel is the channel
// half: the badge rides the existing channel state-mutation fan-out.
func TestNotifyPeerVerifiedChannelInvalidatesAndPushesUpdateChannel(t *testing.T) {
const (
channelID = int64(4004)
ownerID = int64(3003)
memberID = int64(3004)
)
channels := &verifiedNotifyChannels{channel: domain.Channel{
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
Title: "Official", Username: "official", Broadcast: true, Verified: true,
}}
sessions := &captureSessions{
onlineUserIDs: []int64{ownerID, memberID},
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
}
r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
seedChannelFullProjection(t, r, ownerID, channelID)
seedChannelFullProjection(t, r, memberID, channelID)
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: channelID,
}); err != nil {
t.Fatalf("notify peer verified: %v", err)
}
if _, ok := r.channelFullProjectionCache.Lookup(ownerID, channelID); ok {
t.Fatal("owner channelFull projection survived the badge change")
}
if _, ok := r.channelFullProjectionCache.Lookup(memberID, channelID); ok {
t.Fatal("member channelFull projection survived the badge change")
}
if channels.calls != 1 {
t.Fatalf("base channel row reads = %d", channels.calls)
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 2 {
t.Fatalf("pushed user ids = %v", pushed)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
if !ok || refresh.ChannelID != channelID {
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
}
if len(updates.Chats) != 1 {
t.Fatalf("chats = %+v", updates.Chats)
}
channel, ok := updates.Chats[0].(*tg.Channel)
if !ok || channel.ID != channelID || !channel.Verified {
t.Fatalf("channel = %T %+v", updates.Chats[0], updates.Chats[0])
}
if channel.Scam || channel.Fake {
t.Fatalf("badge push leaked moderation flags: %+v", channel)
}
}
// TestNotifyPeerVerifiedNilRouterIsSafe pins the nil-receiver contract: the hook is
// invoked after the decision already committed, so it may never panic.
func TestNotifyPeerVerifiedNilRouterIsSafe(t *testing.T) {
var r *Router
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 1001,
}); err != nil {
t.Fatalf("nil router notify = %v", err)
}
}
// TestNotifyPeerVerifiedWithoutServicesStillInvalidates covers the degraded wiring:
// with no user/channel service the hook cannot push, but the stale projection must
// still be dropped and no error reported.
func TestNotifyPeerVerifiedWithoutServicesStillInvalidates(t *testing.T) {
r := New(Config{}, Deps{}, zap.NewNop(), clock.System)
seedUserFullProjection(t, r, 1001, 2002)
seedChannelFullProjection(t, r, 1001, 4004)
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeUser, ID: 2002,
}); err != nil {
t.Fatalf("notify user without users service = %v", err)
}
if err := r.NotifyPeerVerified(context.Background(), domain.Peer{
Type: domain.PeerTypeChannel, ID: 4004,
}); err != nil {
t.Fatalf("notify channel without channels service = %v", err)
}
if _, ok := r.userFullProjectionCache.Lookup(1001, 2002); ok {
t.Fatal("userFull projection survived without a users service")
}
if _, ok := r.channelFullProjectionCache.Lookup(1001, 4004); ok {
t.Fatal("channelFull projection survived without a channels service")
}
}
// TestNotifyPeerVerifiedRejectsUnknownPeers pins the "explain, never panic"
// contract for every peer the hook cannot act on.
func TestNotifyPeerVerifiedRejectsUnknownPeers(t *testing.T) {
users := &verifiedNotifyUsers{}
channels := &verifiedNotifyChannels{}
sessions := &captureSessions{}
r := New(Config{}, Deps{Users: users, Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
ctx := context.Background()
for _, tc := range []struct {
name string
peer domain.Peer
want string
}{
{"zero id", domain.Peer{Type: domain.PeerTypeUser}, "invalid peer id"},
{"negative id", domain.Peer{Type: domain.PeerTypeChannel, ID: -1}, "invalid peer id"},
{"community peer", domain.Peer{Type: domain.PeerTypeCommunity, ID: 5005}, "unsupported peer type"},
{"empty type", domain.Peer{ID: 5005}, "unsupported peer type"},
{"missing user", domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, "user 2002 not found"},
{"missing channel", domain.Peer{Type: domain.PeerTypeChannel, ID: 4004}, "channel 4004 not found"},
} {
err := r.NotifyPeerVerified(ctx, tc.peer)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: err = %v, want mention of %q", tc.name, err, tc.want)
}
}
if pushed := sessions.pushedUserIDs(); len(pushed) != 0 {
t.Fatalf("unresolved peers pushed updates to %v", pushed)
}
}
// TestNotifyPeerVerifiedReportsLookupFailures keeps a directory error distinct from
// "peer not found", and keeps a channels adapter without the base-row reader from
// failing silently.
func TestNotifyPeerVerifiedReportsLookupFailures(t *testing.T) {
ctx := context.Background()
loadErr := errors.New("boom")
failing := New(Config{}, Deps{
Channels: &verifiedNotifyChannels{err: loadErr},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
err := failing.NotifyPeerVerified(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
if !errors.Is(err, loadErr) {
t.Fatalf("channel load error = %v", err)
}
unwired := New(Config{}, Deps{
Channels: channelsWithoutDirectory{},
Sessions: &captureSessions{},
}, zap.NewNop(), clock.System)
err = unwired.NotifyPeerVerified(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
if err == nil || !strings.Contains(err.Error(), "GetChannelByID") {
t.Fatalf("missing channel directory error = %v", err)
}
}