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:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
|
|
@ -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()),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
616
internal/rpc/bot_verification_flags_test.go
Normal file
616
internal/rpc/bot_verification_flags_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
107
internal/rpc/bot_verification_notify.go
Normal file
107
internal/rpc/bot_verification_notify.go
Normal 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)
|
||||
}
|
||||
335
internal/rpc/bot_verification_notify_test.go
Normal file
335
internal/rpc/bot_verification_notify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
314
internal/rpc/bot_verification_projection.go
Normal file
314
internal/rpc/bot_verification_projection.go
Normal 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
|
||||
}
|
||||
537
internal/rpc/bot_verification_rpc_test.go
Normal file
537
internal/rpc/bot_verification_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
146
internal/rpc/channels_invites_verified_test.go
Normal file
146
internal/rpc/channels_invites_verified_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
709
internal/rpc/collectible_usernames_rpc_test.go
Normal file
709
internal/rpc/collectible_usernames_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
350
internal/rpc/fragment.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 进入 Users(Android 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
|
||||
|
|
|
|||
64
internal/rpc/username_notify.go
Normal file
64
internal/rpc/username_notify.go
Normal 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)
|
||||
}
|
||||
94
internal/rpc/username_notify_test.go
Normal file
94
internal/rpc/username_notify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
117
internal/rpc/verification_notify.go
Normal file
117
internal/rpc/verification_notify.go
Normal 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)
|
||||
}
|
||||
319
internal/rpc/verification_notify_test.go
Normal file
319
internal/rpc/verification_notify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue