Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -25,6 +25,12 @@ const mtprotoPushTokenType = 7
// registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountReportPeerRequest](d, tlprofile.SemanticMethodAccountReportPeer, func(ctx context.Context, req *tg.AccountReportPeerRequest) (any, error) {
return r.onAccountReportPeer(ctx, req)
})
registerRPC[*tg.AccountReportProfilePhotoRequest](d, tlprofile.SemanticMethodAccountReportProfilePhoto, func(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (any, error) {
return r.onAccountReportProfilePhoto(ctx, req)
})
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
return r.onAccountDeleteAccount(ctx, req)
})
@ -69,6 +75,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)
})
@ -216,12 +228,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountGetChatThemesRequest](d, tlprofile.SemanticMethodAccountGetChatThemes, func(ctx context.Context, layerRequest *tg.AccountGetChatThemesRequest) (any, error) {
hash := layerRequest.
Hash
_ = hash
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return tdesktop.ChatThemes(hash), nil
return r.onAccountGetChatThemes(ctx, hash)
})
registerRPC[
@ -293,6 +300,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
return false, tgerr400("WALLPAPER_INVALID")
}
if _, ok := tdesktop.LookupWallPaper(req.Wallpaper); !ok {
r.log.Info("wallpaper reference rejected", wallpaperReferenceLogFields("account.installWallPaper", req.Wallpaper)...)
return false, tgerr400("WALLPAPER_INVALID")
}
return true, nil
@ -526,6 +534,20 @@ func (r *Router) unregisterPushSession(ctx context.Context, tokenType int, token
registrar.UnmarkPushSession(rawAuthKeyID, sessionID)
}
func wallpaperReferenceLogFields(method string, input tg.InputWallPaperClass) []zap.Field {
fields := []zap.Field{zap.String("method", method)}
switch wallpaper := input.(type) {
case *tg.InputWallPaperSlug:
return append(fields, zap.String("input_type", "inputWallPaperSlug"), zap.String("slug", wallpaper.Slug))
case *tg.InputWallPaper:
return append(fields, zap.String("input_type", "inputWallPaper"), zap.Int64("wallpaper_id", wallpaper.ID))
case *tg.InputWallPaperNoFile:
return append(fields, zap.String("input_type", "inputWallPaperNoFile"), zap.Int64("wallpaper_id", wallpaper.ID))
default:
return append(fields, zap.String("input_type", "unknown"))
}
}
func (r *Router) onAccountGetPassword(ctx context.Context) (*tg.AccountPassword, error) {
if r.deps.Account == nil {
return tgPassword(domain.PasswordSettings{SecureRandom: []byte("telesrv-tdesktop-dev-secure-rand")}), nil
@ -657,7 +679,8 @@ func (r *Router) onAccountResetAuthorization(ctx context.Context, hash int64) (b
}
r.revokeAuthKeySessions(deleted.AuthKeyID)
_ = r.clearAuthKeyState(ctx, deleted.AuthKeyID)
// P1 修复:撤销该会话销毁其 auth_key,级联 discard 该设备绑定的活跃密聊并通知对端。
// 撤销该设备的业务授权后,级联 discard 其绑定的活跃密聊并通知对端。
// 协议 auth key 必须保留,供客户端重连后取得 AUTH_KEY_UNREGISTERED。
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(deleted.AuthKeyID), userID)
return true, nil
}
@ -792,7 +815,7 @@ func (r *Router) onAccountVerifyEmail(ctx context.Context, req *tg.AccountVerify
if ClientTypeFrom(ctx) == ClientTypeAndroid {
return &tg.AccountEmailVerifiedLogin{
Email: email,
SentCode: tgEmailSentCode(p.PhoneCodeHash, domain.MaskEmail(email), len(strings.TrimSpace(code)), true),
SentCode: tgEmailSentCode(p.PhoneCodeHash, domain.MaskEmail(email), len(strings.TrimSpace(code)), r.loginEmailResetAvailable()),
}, nil
}
u, _, needSignUp, signInErr := r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), p.PhoneNumber, p.PhoneCodeHash, code)
@ -946,6 +969,10 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
return nil, err
}
r.invalidateRPCProjectionForUser(userID)
// updatePrivacy is an absolute, non-PTS account-state notification. The
// originating session applies account.setPrivacy's response; other online
// sessions receive this best-effort update, while offline sessions reload
// the authoritative rules through account.getPrivacy.
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
@ -953,7 +980,12 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
if domainKey == domain.PrivacyKeyStatusTimestamp {
r.pushStatusPrivacyRefresh(ctx, userID)
}
return out, nil
}
@ -978,10 +1010,11 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
return false, tgerr400("TTL_DAYS_INVALID")
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetAccountTTL(ctx, userID, ttl.Days); err != nil {
saved, err := svc.SetAccountTTL(ctx, userID, ttl.Days)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -1008,7 +1041,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
if err != nil {
return nil, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
}
return &settings, nil
@ -1035,10 +1068,11 @@ func (r *Router) onAccountSetContentSettings(ctx context.Context, req *tg.Accoun
return false, inputRequestInvalidErr()
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled); err != nil {
saved, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -1061,10 +1095,11 @@ func (r *Router) onAccountSetContactSignUpNotification(ctx context.Context, sile
return false, internalErr()
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetContactSignUpSilent(ctx, userID, silent); err != nil {
saved, err := svc.SetContactSignUpSilent(ctx, userID, silent)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -1134,13 +1169,15 @@ func (r *Router) tgAccountPrivacyRules(ctx context.Context, viewerUserID int64,
return nil, internalErr()
}
}
return &tg.AccountPrivacyRules{
out := &tg.AccountPrivacyRules{
Rules: tgPrivacyRules(rules.Rules),
// viewer 可能把自己(inputUserSelf)写进隐私名单,须带 self 标志,否则下发的
// self=false user 会被 DrKLO putUsers 覆盖账号缓存。
Users: tgUsersForViewer(viewerUserID, users),
Chats: []tg.ChatClass{},
}, nil
}
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out, nil
}
func (r *Router) domainPrivacyRulesFromInput(ctx context.Context, userID int64, in []tg.InputPrivacyRuleClass) ([]domain.PrivacyRule, error) {
@ -1568,8 +1605,7 @@ func (r *Router) onAccountUpdateProfile(ctx context.Context, req *tg.AccountUpda
return nil, profileErr(err)
}
r.invalidateRPCProjectionForUser(u.ID)
r.pushUsernameUpdate(ctx, u)
return r.tgSelfUser(u), nil
return r.pushUsernameUpdate(ctx, u), nil
}
func (r *Router) onAccountCheckUsername(ctx context.Context, username string) (bool, error) {
@ -1602,8 +1638,55 @@ func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (
return nil, usernameErr(err)
}
r.invalidateRPCProjectionForUser(u.ID)
r.pushUsernameUpdate(ctx, u)
return r.tgSelfUser(u), nil
return r.pushUsernameUpdate(ctx, 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 缺省即清除;
@ -1716,12 +1799,13 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
}
r.invalidateRPCProjectionForUser(u.ID)
update := &tg.UpdateUserEmojiStatus{UserID: u.ID, EmojiStatus: tgUserEmojiStatusValue(value)}
self := r.tgSelfUserWithUsernames(ctx, u)
if durableWrite {
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date,
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{self}, Date: event.Date,
})
} else if updates, ok := r.deps.Updates.(UserEmojiStatusUpdatesService); ok {
event, _, recordErr := updates.RecordUserEmojiStatus(ctx, authKeyID, userID, value, rawAuthKeyIDForOrigin(ctx), sessionID)
@ -1732,13 +1816,13 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date,
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{self}, Date: event.Date,
})
} else {
// Lightweight test deployments without the durable extension retain the
// previous online-only behavior; production wiring implements it.
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: int(r.clock.Now().Unix()),
Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{self}, Date: int(r.clock.Now().Unix()),
})
}
return true, nil
@ -1812,7 +1896,7 @@ func (r *Router) onAccountUpdateColor(ctx context.Context, req *tg.AccountUpdate
r.invalidateRPCProjectionForUser(u.ID)
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}},
Users: []tg.UserClass{r.tgSelfUser(u)},
Users: []tg.UserClass{r.tgSelfUserWithUsernames(ctx, u)},
Date: int(r.clock.Now().Unix()),
})
return true, nil
@ -1916,20 +2000,35 @@ func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
}
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) *tg.User {
// 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.
// Keep the RPC result and pushed update as distinct tg.User objects: TL Encode
// recomputes flags, so sharing one pointer across response and push delivery
// would make otherwise independent encoders mutate the same object.
self := r.tgSelfUser(u)
pushedSelf := r.tgSelfUser(u)
users := []tg.UserClass{self, pushedSelf}
r.applyUsernamesToPeerObjects(ctx, users, nil)
if u.ID == 0 {
return
return self
}
usernames := tgUsernames(u.Username)
if vector, ok := pushedSelf.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: []tg.UserClass{pushedSelf},
Date: int(r.clock.Now().Unix()),
})
return self
}
func (r *Router) pushSelfUserChangedUpdate(ctx context.Context, u domain.User) {
@ -1938,7 +2037,7 @@ func (r *Router) pushSelfUserChangedUpdate(ctx context.Context, u domain.User) {
}
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}},
Users: []tg.UserClass{r.tgSelfUser(u)},
Users: []tg.UserClass{r.tgSelfUserWithUsernames(ctx, u)},
Date: int(r.clock.Now().Unix()),
})
}

View file

@ -239,6 +239,7 @@ func (r *Router) onAccountResolveBusinessChatLink(ctx context.Context, slug stri
}
}
}
r.applyPeerReadModels(ctx, viewerID, users, nil)
return &tg.AccountResolvedBusinessChatLinks{
Peer: &tg.PeerUser{UserID: link.OwnerUserID},
Message: link.Message,
@ -271,10 +272,12 @@ func (r *Router) onAccountGetConnectedBots(ctx context.Context) (*tg.AccountConn
if !found {
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil
}
return &tg.AccountConnectedBots{
out := &tg.AccountConnectedBots{
ConnectedBots: []tg.ConnectedBot{tgConnectedBot(bot)},
Users: []tg.UserClass{r.tgUser(botUser)},
}, nil
}
r.applyPeerReadModels(ctx, userID, out.Users, nil)
return out, nil
}
func (r *Router) onAccountUpdateConnectedBot(ctx context.Context, req *tg.AccountUpdateConnectedBotRequest) (tg.UpdatesClass, error) {
@ -301,7 +304,7 @@ func (r *Router) onAccountUpdateConnectedBot(ctx context.Context, req *tg.Accoun
return nil, businessAutomationErr(err)
}
r.invalidateRPCProjectionForViewer(userID)
return r.connectedBusinessBotEmptyUpdates(botUser), nil
return r.connectedBusinessBotEmptyUpdates(ctx, userID, botUser), nil
}
recipients, err := r.domainBusinessBotRecipients(ctx, userID, req.Recipients)
if err != nil {
@ -316,7 +319,7 @@ func (r *Router) onAccountUpdateConnectedBot(ctx context.Context, req *tg.Accoun
return nil, businessAutomationErr(err)
}
r.invalidateRPCProjectionForViewer(userID)
return r.connectedBusinessBotEmptyUpdates(botUser, tgConnectedBot(saved)), nil
return r.connectedBusinessBotEmptyUpdates(ctx, userID, botUser, tgConnectedBot(saved)), nil
}
func (r *Router) onAccountToggleConnectedBotPaused(ctx context.Context, req *tg.AccountToggleConnectedBotPausedRequest) (bool, error) {
@ -410,17 +413,19 @@ func connectedBusinessBotUsable(u domain.User) bool {
return u.Bot && u.ID != 0 && u.ID != domain.BotFatherUserID
}
func (r *Router) connectedBusinessBotEmptyUpdates(botUser domain.User, bots ...tg.ConnectedBot) *tg.Updates {
func (r *Router) connectedBusinessBotEmptyUpdates(ctx context.Context, viewerUserID int64, botUser domain.User, bots ...tg.ConnectedBot) *tg.Updates {
users := []tg.UserClass{}
if botUser.ID != 0 {
users = append(users, r.tgUser(botUser))
}
return &tg.Updates{
out := &tg.Updates{
Updates: []tg.UpdateClass{},
Users: users,
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
}
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
return out
}
func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUserID int64, peer domain.Peer, settings domain.PeerSettings) (domain.PeerSettings, error) {

View file

@ -183,12 +183,14 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
chats = appendUniqueTGChats(chats, tgCommunityChats(views)...)
}
}
return &tg.Updates{
out := &tg.Updates{
Updates: updates,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
Chats: chats,
Date: int(r.clock.Now().Unix()),
}, nil
}
r.applyPeerReadModels(ctx, userID, out.Users, out.Chats)
return out, nil
}
// notifyExceptionQualifies 判定一条异常是否纳入 getNotifyExceptions 结果。

View file

@ -90,5 +90,5 @@ func (r *Router) onAccountChangePhone(ctx context.Context, req *tg.AccountChange
}
r.bookkeepAuxPtsForCurrentSession(ctx, result.Event)
}
return r.tgSelfUser(result.User), nil
return r.tgSelfUserWithUsernames(ctx, result.User), nil
}

View file

@ -21,7 +21,7 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
auths := memory.NewAuthorizationStore()
codes := memory.NewCodeStore()
events := memory.NewUpdateEventStore()
user, err := users.Create(ctx, domain.User{AccessHash: 401, Phone: "15550013001", FirstName: "Alice"})
user, err := users.Create(ctx, domain.User{AccessHash: 401, Phone: "15550013001", FirstName: "Alice", Username: "Alice"})
if err != nil {
t.Fatalf("create user: %v", err)
}
@ -34,8 +34,13 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
appaccount.WithUsers(users),
appaccount.WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 5),
)
registry := newFakeUsernameRegistry()
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}] = []domain.Username{
{Username: "Alice", Editable: true, Active: true, SortOrder: 0},
{Username: "aliceCollect0728b", Active: true, SortOrder: 1, CollectibleID: 2},
}
sessions := &captureSessions{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions, Usernames: registry}, zaptest.NewLogger(t), clock.System)
reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 77)
sentClass, err := r.onAccountSendChangePhoneCode(reqCtx, &tg.AccountSendChangePhoneCodeRequest{PhoneNumber: "+1 555 001 3002"})
@ -62,6 +67,7 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
if !ok || self.ID != user.ID || self.Phone != "15550013002" {
t.Fatalf("returned self = %T %+v", userClass, userClass)
}
assertVectorOnlyUsernames(t, "account.changePhone", self, []string{"Alice", "aliceCollect0728b"})
otherPush, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(otherPush.Updates) != 2 {

View file

@ -0,0 +1,161 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appprivacy "telesrv/internal/app/privacy"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountPrivacyAllKeysRoundTripWithoutAdvancingPts(t *testing.T) {
ctx := context.Background()
const userID int64 = 8101
authKeyID := [8]byte{8, 1}
sessionID := int64(81)
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
events := memory.NewUpdateEventStore()
updates := appupdates.NewService(memory.NewUpdateStateStore(), events)
sessions := &captureSessions{}
router := New(Config{}, Deps{
Privacy: privacy,
Updates: updates,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
requestCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, userID), authKeyID), sessionID)
keys := []struct {
name string
input tg.InputPrivacyKeyClass
domain domain.PrivacyKey
wire func(tg.PrivacyKeyClass) bool
}{
{"status_timestamp", &tg.InputPrivacyKeyStatusTimestamp{}, domain.PrivacyKeyStatusTimestamp, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyStatusTimestamp); return ok }},
{"chat_invite", &tg.InputPrivacyKeyChatInvite{}, domain.PrivacyKeyChatInvite, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyChatInvite); return ok }},
{"phone_call", &tg.InputPrivacyKeyPhoneCall{}, domain.PrivacyKeyPhoneCall, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneCall); return ok }},
{"phone_p2p", &tg.InputPrivacyKeyPhoneP2P{}, domain.PrivacyKeyPhoneP2P, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneP2P); return ok }},
{"forwards", &tg.InputPrivacyKeyForwards{}, domain.PrivacyKeyForwards, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyForwards); return ok }},
{"profile_photo", &tg.InputPrivacyKeyProfilePhoto{}, domain.PrivacyKeyProfilePhoto, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyProfilePhoto); return ok }},
{"phone_number", &tg.InputPrivacyKeyPhoneNumber{}, domain.PrivacyKeyPhoneNumber, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneNumber); return ok }},
{"added_by_phone", &tg.InputPrivacyKeyAddedByPhone{}, domain.PrivacyKeyAddedByPhone, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyAddedByPhone); return ok }},
{"voice_messages", &tg.InputPrivacyKeyVoiceMessages{}, domain.PrivacyKeyVoiceMessages, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyVoiceMessages); return ok }},
{"about", &tg.InputPrivacyKeyAbout{}, domain.PrivacyKeyAbout, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyAbout); return ok }},
{"birthday", &tg.InputPrivacyKeyBirthday{}, domain.PrivacyKeyBirthday, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyBirthday); return ok }},
{"star_gifts_auto_save", &tg.InputPrivacyKeyStarGiftsAutoSave{}, domain.PrivacyKeyStarGiftsAutoSave, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyStarGiftsAutoSave); return ok }},
{"no_paid_messages", &tg.InputPrivacyKeyNoPaidMessages{}, domain.PrivacyKeyNoPaidMessages, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyNoPaidMessages); return ok }},
{"saved_music", &tg.InputPrivacyKeySavedMusic{}, domain.PrivacyKeySavedMusic, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeySavedMusic); return ok }},
}
for _, test := range keys {
t.Run(test.name, func(t *testing.T) {
gotKey, ok := domainPrivacyKeyFromInput(test.input)
if !ok || gotKey != test.domain {
t.Fatalf("input key maps to %q/%v, want %q/true", gotKey, ok, test.domain)
}
if !test.wire(tgPrivacyKey(test.domain)) {
t.Fatalf("domain key %q projected as %T", test.domain, tgPrivacyKey(test.domain))
}
set, err := router.onAccountSetPrivacy(requestCtx, &tg.AccountSetPrivacyRequest{
Key: test.input,
Rules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueDisallowAll{}},
})
if err != nil {
t.Fatalf("setPrivacy: %v", err)
}
if len(set.Rules) != 1 {
t.Fatalf("setPrivacy rules=%d, want 1", len(set.Rules))
}
if _, ok := set.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("setPrivacy rule=%T, want disallowAll", set.Rules[0])
}
get, err := router.onAccountGetPrivacy(requestCtx, test.input)
if err != nil {
t.Fatalf("getPrivacy: %v", err)
}
if len(get.Rules) != 1 {
t.Fatalf("getPrivacy rules=%d, want 1", len(get.Rules))
}
if _, ok := get.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("getPrivacy rule=%T, want disallowAll", get.Rules[0])
}
pushed, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(pushed.Updates) != 1 {
t.Fatalf("online push=%T/%+v, want one updatePrivacy", sessions.lastUserPush(), pushed)
}
privacyUpdate, ok := pushed.Updates[0].(*tg.UpdatePrivacy)
if !ok {
t.Fatalf("online push update=%T, want updatePrivacy(%q)", pushed.Updates[0], test.domain)
}
if !test.wire(privacyUpdate.Key) {
t.Fatalf("online push key=%T, want %q", privacyUpdate.Key, test.domain)
}
})
}
if pushedUserIDs := sessions.pushedUserIDs(); len(pushedUserIDs) != len(keys) {
t.Fatalf("online privacy pushes=%v, want exactly one per key", pushedUserIDs)
} else {
for i, pushedUserID := range pushedUserIDs {
if pushedUserID != userID {
t.Fatalf("online privacy push[%d] target=%d, want owner %d", i, pushedUserID, userID)
}
}
}
if snapshot := sessions.snapshot(); snapshot.sessionID != sessionID || snapshot.userID != userID {
t.Fatalf("online push exclusion/target=%+v, want current session %d excluded for user %d", snapshot, sessionID, userID)
}
recorded, err := events.ListAfter(ctx, userID, 0, 100)
if err != nil {
t.Fatalf("list account update events: %v", err)
}
if len(recorded) != 0 {
t.Fatalf("account update events=%+v, want none for privacy changes", recorded)
}
state, err := updates.CurrentState(ctx, userID)
if err != nil {
t.Fatalf("current update state: %v", err)
}
if state.Pts != 0 {
t.Fatalf("privacy changes advanced pts to %d, want 0", state.Pts)
}
difference, err := updates.GetDifference(ctx, [8]byte{8, 2}, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("getDifference: %v", err)
}
if difference.State.Pts != 0 || len(difference.Events) != 0 {
t.Fatalf("difference after privacy changes=%+v, want empty pts=0", difference)
}
// A real message-box update immediately after privacy changes must still
// receive pts=1. This catches both hidden privacy allocations and gaps left
// behind by synthetic bookkeeping events.
message := domain.Message{
ID: 1,
OwnerUserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 8102},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 8102},
Date: 1700000000,
Body: "after privacy",
}
event, state, err := updates.RecordNewMessage(ctx, authKeyID, userID, message)
if err != nil {
t.Fatalf("record adjacent message update: %v", err)
}
if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 {
t.Fatalf("adjacent message event/state=%+v/%+v, want first pts=1", event, state)
}
difference, err = updates.GetDifference(ctx, [8]byte{8, 2}, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("getDifference after message: %v", err)
}
if difference.State.Pts != 1 || len(difference.Events) != 1 || difference.Events[0].Type != domain.UpdateEventNewMessage {
t.Fatalf("difference after adjacent message=%+v, want one contiguous new_message at pts=1", difference)
}
}

View file

@ -0,0 +1,272 @@
package rpc
import (
"context"
"errors"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type fakeAccountRatingProjection struct {
byUser map[int64]domain.AccountRating
err error
ratingCalls int
ensureCalls int
}
func (f *fakeAccountRatingProjection) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
f.ratingCalls++
if f.err != nil {
return domain.AccountRating{}, f.err
}
rating, ok := f.byUser[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// EnsureRating deliberately exists on the fake even though it is not part of
// AccountRatingService. The assertion below pins that profile reads stay
// read-only if a future implementation happens to expose a materializer.
func (f *fakeAccountRatingProjection) EnsureRating(_ context.Context, userID int64) (domain.AccountRating, error) {
f.ensureCalls++
return f.byUser[userID], nil
}
var _ AccountRatingService = (*fakeAccountRatingProjection)(nil)
func newAccountRatingProjectionFixture(t *testing.T, ratings AccountRatingService) (*Router, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{
AccessHash: 11,
Phone: "15550004001",
FirstName: "Owner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
other, err := userStore.Create(ctx, domain.User{
AccessHash: 22,
Phone: "15550004002",
FirstName: "Other",
})
if err != nil {
t.Fatalf("create other: %v", err)
}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
AccountRatings: ratings,
}, zaptest.NewLogger(t), clock.System)
return router, owner, other
}
func TestUserFullProjectsCompositeRatingReadOnlyAndCachesIt(t *testing.T) {
pendingDate := time.Unix(1800000000, 0).UTC()
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
router, owner, _ := newAccountRatingProjectionFixture(t, ratings)
ratings.byUser[owner.ID] = domain.AccountRating{
UserID: owner.ID,
Level: 3,
Stars: 1200,
CurrentLevelStars: domain.AccountRatingLevelThreshold(3),
NextLevelStars: domain.AccountRatingLevelThreshold(4),
HasNextLevel: true,
PendingStars: 500,
PendingDate: pendingDate,
}
ctx := WithUserID(context.Background(), owner.ID)
full, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{})
if err != nil {
t.Fatalf("get self full user: %v", err)
}
rating, ok := full.FullUser.GetStarsRating()
if !ok || rating.Level != 3 || rating.Stars != 1200 ||
rating.CurrentLevelStars != domain.AccountRatingLevelThreshold(3) {
t.Fatalf("self rating = %+v (present=%v)", rating, ok)
}
if next, ok := rating.GetNextLevelStars(); !ok || next != domain.AccountRatingLevelThreshold(4) {
t.Fatalf("self next_level_stars = %d (present=%v)", next, ok)
}
pending, ok := full.FullUser.GetStarsMyPendingRating()
if !ok || pending.Stars != 1700 {
t.Fatalf("self pending rating = %+v (present=%v)", pending, ok)
}
if date, ok := full.FullUser.GetStarsMyPendingRatingDate(); !ok || date != int(pendingDate.Unix()) {
t.Fatalf("self pending date = %d (present=%v)", date, ok)
}
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
t.Fatalf("rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
}
// The second response is served from the existing UserFull projection cache:
// the rating read cannot become a per-request database query.
if _, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{}); err != nil {
t.Fatalf("get cached self full user: %v", err)
}
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
t.Fatalf("cached rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
}
}
func TestUserFullRatingPendingIsSelfOnly(t *testing.T) {
pendingDate := time.Unix(1800000000, 0).UTC()
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
router, owner, other := newAccountRatingProjectionFixture(t, ratings)
ratings.byUser[other.ID] = domain.AccountRating{
UserID: other.ID,
Level: 1,
Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
NextLevelStars: domain.AccountRatingLevelThreshold(2),
HasNextLevel: true,
PendingStars: 500,
PendingDate: pendingDate,
}
full, err := router.onUsersGetFullUser(
WithUserID(context.Background(), owner.ID),
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
)
if err != nil {
t.Fatalf("get other full user: %v", err)
}
if rating, ok := full.FullUser.GetStarsRating(); !ok || rating.Level != 1 || rating.Stars != 150 {
t.Fatalf("other rating = %+v (present=%v)", rating, ok)
}
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
t.Fatal("other pending rating is visible")
}
if _, ok := full.FullUser.GetStarsMyPendingRatingDate(); ok {
t.Fatal("other pending rating date is visible")
}
}
func TestUserFullRatingDegradesWithoutStoredProjection(t *testing.T) {
tests := []struct {
name string
ratings AccountRatingService
}{
{name: "service absent"},
{name: "row missing", ratings: &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{}}},
{name: "read failure", ratings: &fakeAccountRatingProjection{
byUser: map[int64]domain.AccountRating{},
err: errors.New("rating unavailable"),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, owner, _ := newAccountRatingProjectionFixture(t, tt.ratings)
full, err := router.onUsersGetFullUser(
WithUserID(context.Background(), owner.ID),
&tg.InputUserSelf{},
)
if err != nil {
t.Fatalf("get full user: %v", err)
}
if _, ok := full.FullUser.GetStarsRating(); ok {
t.Fatal("rating set without a stored projection")
}
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
t.Fatal("pending rating set without a stored projection")
}
})
}
}
func TestUserFullRatingOmitsBotsAndTopLevelThreshold(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
viewer, err := userStore.Create(ctx, domain.User{
AccessHash: 31,
Phone: "15550004101",
FirstName: "Viewer",
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
bot, err := userStore.Create(ctx, domain.User{
AccessHash: 32,
Phone: "15550004102",
FirstName: "Helper",
Bot: true,
})
if err != nil {
t.Fatalf("create bot: %v", err)
}
ratings := &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{
viewer.ID: {
UserID: viewer.ID,
Level: domain.MaxAccountRatingLevel,
Stars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
CurrentLevelStars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
},
bot.ID: {
UserID: bot.ID,
Level: 4,
Stars: 2000,
},
domain.OfficialSystemUserID: {
UserID: domain.OfficialSystemUserID,
Level: 5,
Stars: 3000,
},
}}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
AccountRatings: ratings,
}, zaptest.NewLogger(t), clock.System)
viewerCtx := WithUserID(ctx, viewer.ID)
own, err := router.onUsersGetFullUser(viewerCtx, &tg.InputUserSelf{})
if err != nil {
t.Fatalf("get own full user: %v", err)
}
rating, ok := own.FullUser.GetStarsRating()
if !ok || rating.Level != domain.MaxAccountRatingLevel {
t.Fatalf("top-level rating = %+v (present=%v)", rating, ok)
}
if _, ok := rating.GetNextLevelStars(); ok {
t.Fatal("next_level_stars set at the maximum level")
}
botFull, err := router.onUsersGetFullUser(
viewerCtx,
&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash},
)
if err != nil {
t.Fatalf("get bot full user: %v", err)
}
if _, ok := botFull.FullUser.GetStarsRating(); ok {
t.Fatal("bot rating is visible")
}
official, err := router.onUsersGetFullUser(
viewerCtx,
&tg.InputUser{
UserID: domain.OfficialSystemUserID,
AccessHash: domain.OfficialSystemUser().AccessHash,
},
)
if err != nil {
t.Fatalf("get official system user: %v", err)
}
if _, ok := official.FullUser.GetStarsRating(); ok {
t.Fatal("system account rating is visible")
}
// One read for the ratable viewer and none for the bot/system guards.
if ratings.ratingCalls != 1 {
t.Fatalf("rating calls = %d, want 1", ratings.ratingCalls)
}
}

View file

@ -0,0 +1,108 @@
package rpc
import (
"context"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func (r *Router) onAccountReportPeer(ctx context.Context, req *tg.AccountReportPeerRequest) (bool, error) {
if req == nil || req.Reason == nil {
return false, inputRequestInvalidErr()
}
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
return false, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
reason, ok := moderationReasonFromReportReason(req.Reason)
if !ok {
return false, tgerr.New(400, "REASON_INVALID")
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportPeer(
ctx, userID, domain.ModerationSourceAccountPeer, target,
reason, string(reason), req.Message, r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
func (r *Router) onAccountReportProfilePhoto(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (bool, error) {
if req == nil || req.Reason == nil {
return false, inputRequestInvalidErr()
}
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
return false, limitInvalidErr()
}
photo, ok := req.PhotoID.(*tg.InputPhoto)
if !ok || photo == nil || photo.ID <= 0 {
return false, photoInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
reason, ok := moderationReasonFromReportReason(req.Reason)
if !ok {
return false, tgerr.New(400, "REASON_INVALID")
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportProfilePhoto(ctx, domain.ModerationProfilePhotoReportRequest{
ReporterUserID: userID, Target: target, PhotoID: photo.ID,
AccessHash: photo.AccessHash, FileReference: append([]byte(nil), photo.FileReference...),
Reason: reason, Comment: req.Message, CreatedAt: r.clock.Now(),
}); err != nil {
if err == domain.ErrModerationEvidenceNotFound {
return false, photoInvalidErr()
}
return false, moderationReportError(err)
}
return true, nil
}
func moderationReasonFromReportReason(reason tg.ReportReasonClass) (domain.ModerationReason, bool) {
switch reason.(type) {
case *tg.InputReportReasonSpam:
return domain.ModerationReasonSpam, true
case *tg.InputReportReasonViolence:
return domain.ModerationReasonViolence, true
case *tg.InputReportReasonPornography:
return domain.ModerationReasonPornography, true
case *tg.InputReportReasonChildAbuse:
return domain.ModerationReasonChildAbuse, true
case *tg.InputReportReasonOther:
return domain.ModerationReasonOther, true
case *tg.InputReportReasonCopyright:
return domain.ModerationReasonCopyright, true
case *tg.InputReportReasonGeoIrrelevant:
return domain.ModerationReasonGeoIrrelevant, true
case *tg.InputReportReasonFake:
return domain.ModerationReasonFake, true
case *tg.InputReportReasonIllegalDrugs:
return domain.ModerationReasonIllegalDrugs, true
case *tg.InputReportReasonPersonalDetails:
return domain.ModerationReasonPersonalDetails, true
default:
return "", false
}
}

View file

@ -0,0 +1,71 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountReportPeerPersistsImmutableSnapshot(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
reporter, err := users.Create(ctx, domain.User{
AccessHash: 101, Phone: "15550005001", FirstName: "Reporter",
})
if err != nil {
t.Fatal(err)
}
target, err := users.Create(ctx, domain.User{
AccessHash: 202, Phone: "15550005002", FirstName: "Target",
Username: "reported_target", About: "original bio",
})
if err != nil {
t.Fatal(err)
}
userService := appusers.NewService(users)
reports := memory.NewModerationReportStore()
router := New(Config{}, Deps{
Users: userService,
Moderation: appmoderation.NewService(
reports, appmoderation.WithPeerReaders(userService, nil),
),
}, zaptest.NewLogger(t), clock.System)
ok, err := router.onAccountReportPeer(
WithUserID(ctx, reporter.ID),
&tg.AccountReportPeerRequest{
Peer: &tg.InputPeerUser{
UserID: target.ID, AccessHash: target.AccessHash,
},
Reason: &tg.InputReportReasonFake{},
Message: "This profile impersonates someone.",
},
)
if err != nil || !ok {
t.Fatalf("report peer ok=%v err=%v", ok, err)
}
stored := reports.Reports()
if len(stored) != 1 ||
stored[0].ReporterUserID != reporter.ID ||
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) ||
stored[0].Reason != domain.ModerationReasonFake ||
len(stored[0].Items) != 1 ||
stored[0].Items[0].Kind != domain.ModerationItemPeer {
t.Fatalf("stored report=%+v", stored)
}
if _, err := users.UpdateProfile(ctx, target.ID, "Changed", "", "changed later"); err != nil {
t.Fatal(err)
}
again, found, err := reports.GetModerationReport(ctx, stored[0].ID)
if err != nil || !found ||
string(again.Items[0].Evidence) != string(stored[0].Items[0].Evidence) {
t.Fatalf("immutable snapshot=%+v found=%v err=%v", again, found, err)
}
}

View file

@ -10,9 +10,10 @@ import (
const (
accountSettingsCacheMaxEntries = 4096
// accountSettingsCacheTTL 兜底跨实例失效;同实例 Set 即时失效。设置页连续调
// getGlobalPrivacy/getAccountTTL/getContentSettings/getContactSignUp 时只查一次 PG。
accountSettingsCacheTTL = 60 * time.Second
// accountSettingsCacheTTL is only the lost-notification safety net. Normal
// consistency comes from account_settings read-model notifications; local
// writes update the cached value directly.
accountSettingsCacheTTL = 24 * time.Hour
)
// accountSettingsCache 缓存 userID→AccountSettings,避免设置页 4 个 get handler 各查
@ -45,6 +46,78 @@ func (c *accountSettingsCache) Delete(userID int64) {
c.cache.Invalidate(userID)
}
func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettings) {
if c == nil || userID == 0 {
return
}
c.cache.Store(userID, settings)
}
func (c *accountSettingsCache) Flush() {
if c == nil {
return
}
c.cache.Flush()
}
// InvalidateAccountSettingsReadModel is called by the shared PostgreSQL
// read-model listener. Router owns this cache, so exposing the invalidation on
// Router keeps store/postgres independent from the RPC package.
func (r *Router) InvalidateAccountSettingsReadModel(userID int64) {
if r == nil || r.accountSettings == nil {
return
}
r.accountSettings.Delete(userID)
}
func (r *Router) FlushAccountSettingsReadModel() {
if r == nil || r.accountSettings == nil {
return
}
r.accountSettings.Flush()
}
func (r *Router) WarmAccountSettingsReadModel(ctx context.Context, userID int64) error {
if r == nil || userID == 0 {
return nil
}
_, err := r.cachedAccountSettings(ctx, userID)
return err
}
type accountSettingsBatchReader interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}
func (c *accountSettingsCache) getOrLoadBatch(
ctx context.Context,
userIDs []int64,
svc accountSettingsService,
) (map[int64]domain.AccountSettings, error) {
if len(userIDs) == 0 {
return map[int64]domain.AccountSettings{}, nil
}
return c.cache.GetOrLoadBatch(
ctx,
userIDs,
func(int64) (int64, bool) { return 0, true },
func(ctx context.Context, missing []int64) (map[int64]domain.AccountSettings, error) {
if batch, ok := svc.(accountSettingsBatchReader); ok {
return batch.GetAccountSettingsBatch(ctx, missing)
}
out := make(map[int64]domain.AccountSettings, len(missing))
for _, userID := range missing {
settings, err := svc.GetAccountSettings(ctx, userID)
if err != nil {
return nil, err
}
out[userID] = settings
}
return out, nil
},
)
}
// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
svc, ok := r.accountSettingsSvc()

View file

@ -6,6 +6,7 @@ import (
"github.com/iamxvbaba/td/tg"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
@ -45,13 +46,45 @@ func (r *Router) onAccountGetThemes(ctx context.Context, req *tg.AccountGetTheme
}
}
}
hash := themesListHash(themes)
if ClientTypeFrom(ctx) == ClientTypeIOS {
themes = ioscompat.ProjectThemes(themes)
}
hash, err := themesListHash(themes)
if err != nil {
return nil, internalErr()
}
if req != nil && req.GetHash() == hash {
return &tg.AccountThemesNotModified{}, nil
}
return &tg.AccountThemes{Hash: hash, Themes: themes}, nil
}
// onAccountGetChatThemes applies the same iOS ARGB projection as account.getThemes.
// The projected content hash is intentionally distinct from the historical
// Android/TDesktop catalog hash, forcing clients with the transparent-color
// response cached to fetch the corrected payload once.
func (r *Router) onAccountGetChatThemes(ctx context.Context, hash int64) (tg.AccountThemesClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
if ClientTypeFrom(ctx) != ClientTypeIOS {
return tdesktop.ChatThemes(hash), nil
}
base, ok := tdesktop.ChatThemes(0).(*tg.AccountThemes)
if !ok {
return nil, internalErr()
}
themes := ioscompat.ProjectThemes(base.Themes)
projectedHash, err := themesListHash(themes)
if err != nil {
return nil, internalErr()
}
if hash == projectedHash {
return &tg.AccountThemesNotModified{}, nil
}
return &tg.AccountThemes{Hash: projectedHash, Themes: themes}, nil
}
// onAccountUploadTheme 把客户端上传的 .attheme 文件落成可下载的 Document 并返回。
// 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。
func (r *Router) onAccountUploadTheme(ctx context.Context, req *tg.AccountUploadThemeRequest) (tg.DocumentClass, error) {
@ -128,7 +161,7 @@ func (r *Router) onAccountCreateTheme(ctx context.Context, req *tg.AccountCreate
return nil, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return r.tgTheme(ctx, t, userID), nil
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
}
// onAccountUpdateTheme 更新创建者自己的主题(部分字段)。
@ -172,7 +205,7 @@ func (r *Router) onAccountUpdateTheme(ctx context.Context, req *tg.AccountUpdate
return nil, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return r.tgTheme(ctx, t, userID), nil
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
}
// onAccountSaveTheme 把主题加入/移出用户的已存列表。
@ -187,6 +220,12 @@ func (r *Router) onAccountSaveTheme(ctx context.Context, req *tg.AccountSaveThem
if userID == 0 {
return false, authKeyUnregisteredErr()
}
if _, ok := tdesktop.LookupDefaultTheme(req.Theme); ok {
// Default catalog themes are always present in account.getThemes. Saving
// or unsaving one is therefore an idempotent signal and must not create a
// synthetic custom-theme row or user install.
return true, nil
}
if r.deps.Themes == nil {
return false, notImplementedErr()
}
@ -219,14 +258,20 @@ func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInsta
if userID == 0 {
return false, authKeyUnregisteredErr()
}
if r.deps.Themes == nil {
return false, notImplementedErr()
}
dark := req.GetDark()
theme, ok := req.GetTheme()
if !ok {
return true, nil // 无 theme 引用:基础主题 no-op 安装
}
if _, ok := tdesktop.LookupDefaultTheme(theme); ok {
// The immutable defaults were issued by account.getThemes but do not
// live in the custom theme store. Applying one is a successful signal;
// the Android client owns the active day/night choice locally.
return true, nil
}
if r.deps.Themes == nil {
return false, notImplementedErr()
}
ref, ok := themeRefFromInput(theme)
if !ok {
return false, themeInvalidErr()
@ -247,6 +292,9 @@ func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeR
if err != nil {
return nil, internalErr()
}
if t, ok := tdesktop.LookupDefaultTheme(req.Theme); ok {
return projectThemeForClient(ctx, &t), nil
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
@ -261,5 +309,16 @@ func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeR
if !ok {
return nil, themeInvalidErr()
}
return r.tgTheme(ctx, t, userID), nil
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
}
func projectThemeForClient(ctx context.Context, theme *tg.Theme) *tg.Theme {
if theme == nil || ClientTypeFrom(ctx) != ClientTypeIOS {
return theme
}
projected := ioscompat.ProjectThemes([]tg.Theme{*theme})
if len(projected) == 0 {
return theme
}
return &projected[0]
}

View file

@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
@ -119,6 +120,26 @@ func TestLegacyThemeWireDispatch(t *testing.T) {
t.Fatalf("installTheme legacy wire id = %#x err=%v, want boolTrue", id, err)
}
// 同一 legacy overlay 必须接受 account.getThemes 下发的静态默认主题,
// 且不能要求该引用存在于自定义主题 store。
defaultTheme := tdesktop.DefaultThemeList()[0]
var defaultIB bin.Buffer
defaultIB.PutID(legacyInstallThemeID)
defaultIB.PutInt32((1 << 0) | (1 << 1))
defaultIB.PutString("android")
(&tg.InputTheme{ID: defaultTheme.ID, AccessHash: defaultTheme.AccessHash}).Encode(&defaultIB)
enc, err = r.Dispatch(ctx, authKeyID, sessionID, &defaultIB)
if err != nil {
t.Fatalf("installTheme legacy default dispatch: %v", err)
}
boolWire.Reset()
if err := enc.Encode(&boolWire); err != nil {
t.Fatalf("encode installTheme legacy default result: %v", err)
}
if id, err := boolWire.ID(); err != nil || id != tg.BoolTrueTypeID {
t.Fatalf("installTheme legacy default wire id = %#x err=%v, want boolTrue", id, err)
}
// 已声明 legacy 方法仍必须由静态 decoder 精确消费完整结构。
var malformed bin.Buffer
malformed.PutID(legacyCreateThemeID)

View file

@ -11,6 +11,7 @@ import (
"go.uber.org/zap/zaptest"
themesapp "telesrv/internal/app/themes"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -125,6 +126,95 @@ func TestAccountCreateThemeFullFlow(t *testing.T) {
}
}
func TestAccountDefaultThemeReferencesResolveWithoutCustomPersistence(t *testing.T) {
const userID = 1000012
ctx := WithClientInfo(
WithUserID(context.Background(), userID),
ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.9.0 (69669)"},
)
r := newThemeRouter(t, &fakeFiles{})
defaults := tdesktop.DefaultThemeList()
if len(defaults) == 0 {
t.Fatal("default theme catalog is empty")
}
for i, theme := range defaults {
t.Run(theme.Slug, func(t *testing.T) {
input := &tg.InputTheme{ID: theme.ID, AccessHash: theme.AccessHash}
install := &tg.AccountInstallThemeRequest{}
install.SetDark(i%2 == 0)
install.SetTheme(input)
install.SetFormat("android")
if ok, err := r.onAccountInstallTheme(ctx, install); err != nil || !ok {
t.Fatalf("install default theme %d = %v/%v, want true/nil", theme.ID, ok, err)
}
for _, unsave := range []bool{false, true} {
if ok, err := r.onAccountSaveTheme(ctx, &tg.AccountSaveThemeRequest{
Theme: input,
Unsave: unsave,
}); err != nil || !ok {
t.Fatalf("save default theme %d unsave=%v = %v/%v, want true/nil", theme.ID, unsave, ok, err)
}
}
got, err := r.onAccountGetTheme(ctx, &tg.AccountGetThemeRequest{
Format: "android",
Theme: &tg.InputThemeSlug{Slug: theme.Slug},
})
if err != nil || got.ID != theme.ID || got.AccessHash != theme.AccessHash {
t.Fatalf("get default theme %d = %#v/%v", theme.ID, got, err)
}
if ok, err := r.onAccountSaveTheme(ctx, &tg.AccountSaveThemeRequest{
Theme: &tg.InputThemeSlug{Slug: theme.Slug},
}); err != nil || !ok {
t.Fatalf("save default theme slug %q = %v/%v, want true/nil", theme.Slug, ok, err)
}
})
}
// Reproduce issue #29 through the canonical Layer 228 wire constructor,
// not only by invoking the typed handler directly.
canonical := &tg.AccountInstallThemeRequest{}
canonical.SetDark(true)
canonical.SetTheme(&tg.InputTheme{ID: defaults[0].ID, AccessHash: defaults[0].AccessHash})
canonical.SetFormat("android")
var body bin.Buffer
if err := canonical.Encode(&body); err != nil {
t.Fatalf("encode canonical installTheme: %v", err)
}
var authKeyID [8]byte
authKeyID[0] = 2
encoded, err := r.Dispatch(ctx, authKeyID, 1001, &body)
if err != nil {
t.Fatalf("canonical installTheme dispatch: %v", err)
}
var result bin.Buffer
if err := encoded.Encode(&result); err != nil {
t.Fatalf("encode canonical installTheme result: %v", err)
}
if id, err := result.ID(); err != nil || id != tg.BoolTrueTypeID {
t.Fatalf("canonical installTheme result id = %#x err=%v, want boolTrue", id, err)
}
installed, err := r.deps.Themes.ListInstalled(ctx, userID)
if err != nil {
t.Fatalf("list custom installs after default signals: %v", err)
}
if len(installed) != 0 {
t.Fatalf("default signals created %d custom installs, want 0", len(installed))
}
forged := defaults[0]
badInstall := &tg.AccountInstallThemeRequest{}
badInstall.SetTheme(&tg.InputTheme{ID: forged.ID, AccessHash: forged.AccessHash + 1})
badInstall.SetFormat("android")
if _, err := r.onAccountInstallTheme(ctx, badInstall); !tgerr.Is(err, "THEME_INVALID") {
t.Fatalf("install forged default reference err = %v, want THEME_INVALID", err)
}
}
// TestAccountGetThemesIncludesUserThemes 验证 getThemes 跨设备同步:返回内置默认主题
// (is_default=true,emoji 预览条用)+ 当前用户创建的自定义主题(is_default=false,creator=true);
// hash 稳定→NotModified,集合变化→重取。
@ -231,6 +321,204 @@ func TestAccountGetThemesTDesktopExcludesDocumentlessDefaults(t *testing.T) {
}
}
func TestAccountGetThemesIOSProjectsOpaqueARGBAndRefreshesByContent(t *testing.T) {
const userID = 1000008
r := newThemeRouter(t, &fakeFiles{})
iosCtx := WithClientInfo(
WithUserID(context.Background(), userID),
ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.2 (10000)"},
)
first, err := r.onAccountGetThemes(iosCtx, &tg.AccountGetThemesRequest{Format: "ios"})
if err != nil {
t.Fatalf("getThemes ios err = %v", err)
}
themes, ok := first.(*tg.AccountThemes)
if !ok || len(themes.Themes) == 0 {
t.Fatalf("getThemes ios = %#v, want non-empty themes", first)
}
assertThemeAccentColorsOpaque(t, themes.Themes)
source := tdesktop.DefaultThemeList()
sourceSettings, _ := source[0].GetSettings()
projectedSettings, _ := themes.Themes[0].GetSettings()
if got, want := uint32(int32(projectedSettings[0].AccentColor))&0x00ffffff, uint32(sourceSettings[0].AccentColor); got != want {
t.Fatalf("projected RGB = %#06x, want source RGB %#06x", got, want)
}
if uint32(int32(sourceSettings[0].AccentColor))>>24 != 0 {
t.Fatalf("source Android/TDesktop accent unexpectedly changed to ARGB: %#08x", uint32(int32(sourceSettings[0].AccentColor)))
}
again, err := r.onAccountGetThemes(iosCtx, &tg.AccountGetThemesRequest{Format: "ios", Hash: themes.Hash})
if err != nil {
t.Fatalf("getThemes ios matching hash err = %v", err)
}
if _, ok := again.(*tg.AccountThemesNotModified); !ok {
t.Fatalf("getThemes ios matching hash = %T, want notModified", again)
}
}
func TestAccountGetChatThemesIOSInvalidatesTransparentCatalogCache(t *testing.T) {
const userID = 1000009
r := newThemeRouter(t, &fakeFiles{})
base, ok := tdesktop.ChatThemes(0).(*tg.AccountThemes)
if !ok {
t.Fatalf("base ChatThemes = %T, want *tg.AccountThemes", tdesktop.ChatThemes(0))
}
iosCtx := WithClientInfo(
WithUserID(context.Background(), userID),
ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.2 (10000)"},
)
projected, err := r.onAccountGetChatThemes(iosCtx, base.Hash)
if err != nil {
t.Fatalf("getChatThemes ios err = %v", err)
}
themes, ok := projected.(*tg.AccountThemes)
if !ok {
t.Fatalf("getChatThemes ios with old hash = %T, want refreshed themes", projected)
}
if themes.Hash == base.Hash {
t.Fatalf("iOS projected hash = old catalog hash %d, want cache invalidation", themes.Hash)
}
assertThemeAccentColorsOpaque(t, themes.Themes)
again, err := r.onAccountGetChatThemes(iosCtx, themes.Hash)
if err != nil {
t.Fatalf("getChatThemes ios matching hash err = %v", err)
}
if _, ok := again.(*tg.AccountThemesNotModified); !ok {
t.Fatalf("getChatThemes ios matching hash = %T, want notModified", again)
}
androidCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeAndroid})
android, err := r.onAccountGetChatThemes(androidCtx, base.Hash)
if err != nil {
t.Fatalf("getChatThemes android old hash err = %v", err)
}
if _, ok := android.(*tg.AccountThemesNotModified); !ok {
t.Fatalf("getChatThemes android old hash = %T, want unchanged notModified", android)
}
}
func TestThemesListHashIncludesVisibleContentAndIgnoresOrder(t *testing.T) {
first := tg.Theme{ID: 1, AccessHash: 11, Slug: "one", Title: "One"}
first.SetSettings([]tg.ThemeSettings{{
BaseTheme: &tg.BaseThemeClassic{},
AccentColor: 0x29b071,
}})
second := tg.Theme{ID: 2, AccessHash: 22, Slug: "two", Title: "Two"}
hashA, err := themesListHash([]tg.Theme{first, second})
if err != nil {
t.Fatalf("themesListHash initial: %v", err)
}
hashReordered, err := themesListHash([]tg.Theme{second, first})
if err != nil {
t.Fatalf("themesListHash reordered: %v", err)
}
if hashA != hashReordered {
t.Fatalf("hash changed with order: %d != %d", hashA, hashReordered)
}
changed := first
changedSettings, _ := changed.GetSettings()
changedSettings = append([]tg.ThemeSettings(nil), changedSettings...)
changedSettings[0].AccentColor = 0x329ed7
changed.SetSettings(changedSettings)
hashChanged, err := themesListHash([]tg.Theme{changed, second})
if err != nil {
t.Fatalf("themesListHash changed: %v", err)
}
if hashChanged == hashA {
t.Fatalf("hash did not change after accent update: %d", hashA)
}
}
func TestAccountSingleThemeResponsesUseIOSARGBProjection(t *testing.T) {
const userID = 1000011
r := newThemeRouter(t, &fakeFiles{})
androidCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeAndroid})
iosCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeIOS})
input := tg.InputThemeSettings{
BaseTheme: &tg.BaseThemeDay{},
AccentColor: 0x3997d3,
}
input.SetOutboxAccentColor(0x4cb064)
create := &tg.AccountCreateThemeRequest{Title: "Cross-platform"}
create.SetSettings([]tg.InputThemeSettings{input})
created, err := r.onAccountCreateTheme(androidCtx, create)
if err != nil {
t.Fatalf("create Android theme: %v", err)
}
androidSettings, _ := created.GetSettings()
if color := uint32(int32(androidSettings[0].AccentColor)); color>>24 != 0 {
t.Fatalf("Android create response accent = %#08x, want unchanged RGB24", color)
}
got, err := r.onAccountGetTheme(iosCtx, &tg.AccountGetThemeRequest{
Format: "ios",
Theme: &tg.InputTheme{ID: created.ID, AccessHash: created.AccessHash},
})
if err != nil {
t.Fatalf("get iOS theme: %v", err)
}
assertThemeAccentColorsOpaque(t, []tg.Theme{*got})
iosSettings, _ := got.GetSettings()
if color := uint32(int32(iosSettings[0].AccentColor)); color != 0xff3997d3 {
t.Fatalf("iOS getTheme accent = %#08x, want 0xff3997d3", color)
}
if color, ok := iosSettings[0].GetOutboxAccentColor(); !ok || uint32(int32(color)) != 0xff4cb064 {
t.Fatalf("iOS getTheme outbox accent = %#08x ok=%v, want 0xff4cb064", uint32(int32(color)), ok)
}
iosCreate := &tg.AccountCreateThemeRequest{Title: "Created on iOS"}
iosCreate.SetSettings([]tg.InputThemeSettings{input})
createdOnIOS, err := r.onAccountCreateTheme(iosCtx, iosCreate)
if err != nil {
t.Fatalf("create iOS theme: %v", err)
}
assertThemeAccentColorsOpaque(t, []tg.Theme{*createdOnIOS})
updatedInput := tg.InputThemeSettings{
BaseTheme: &tg.BaseThemeTinted{},
AccentColor: 0x8660ad,
}
update := &tg.AccountUpdateThemeRequest{
Format: "ios",
Theme: &tg.InputTheme{ID: created.ID, AccessHash: created.AccessHash},
}
update.SetSettings([]tg.InputThemeSettings{updatedInput})
updated, err := r.onAccountUpdateTheme(iosCtx, update)
if err != nil {
t.Fatalf("update iOS theme: %v", err)
}
assertThemeAccentColorsOpaque(t, []tg.Theme{*updated})
updatedSettings, _ := updated.GetSettings()
if color := uint32(int32(updatedSettings[0].AccentColor)); color != 0xff8660ad {
t.Fatalf("iOS updateTheme accent = %#08x, want 0xff8660ad", color)
}
}
func assertThemeAccentColorsOpaque(t *testing.T, themes []tg.Theme) {
t.Helper()
for _, theme := range themes {
settings, ok := theme.GetSettings()
if !ok {
continue
}
for i := range settings {
if color := uint32(int32(settings[i].AccentColor)); color>>24 == 0 {
t.Fatalf("theme %d settings[%d] accent remains transparent: %#08x", theme.ID, i, color)
}
if color, ok := settings[i].GetOutboxAccentColor(); ok && uint32(int32(color))>>24 == 0 {
t.Fatalf("theme %d settings[%d] outbox accent remains transparent: %#08x", theme.ID, i, uint32(int32(color)))
}
}
}
}
// TestAccountCreateThemeAccentSettingsEncode 验证带 settings 的 accent 主题往返且 base_theme 非空可编码。
func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) {
ctx := WithUserID(context.Background(), 1000003)

View file

@ -63,6 +63,9 @@ func (r *Router) registerAuth(d *tlprofile.Dispatcher) {
registerRPC[*tg.AuthSendCodeRequest](d, tlprofile.SemanticMethodAuthSendCode, func(ctx context.Context, layerRequest *tg.AuthSendCodeRequest) (any, error) {
return r.onAuthSendCode(ctx, layerRequest)
})
registerRPC[*tg.AuthReportMissingCodeRequest](d, tlprofile.SemanticMethodAuthReportMissingCode, func(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (any, error) {
return r.onAuthReportMissingCode(ctx, req)
})
registerRPC[*tg.AuthResendCodeRequest](d, tlprofile.SemanticMethodAuthResendCode, func(ctx context.Context, layerRequest *tg.AuthResendCodeRequest) (any, error) {
return r.onAuthResendCode(ctx, layerRequest)
})
@ -407,7 +410,7 @@ func (r *Router) authLoginTokenSuccess(ctx context.Context, a domain.Authorizati
return nil, internalErr()
}
return &tg.AuthLoginTokenSuccess{
Authorization: &tg.AuthAuthorization{User: r.tgSelfUser(u)},
Authorization: &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)},
}, nil
}
@ -475,6 +478,36 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
return r.tgSentCodeForHash(ctx, hash)
}
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
if req == nil || r.deps.AuthDeliveryReports == nil {
return false, inputRequestInvalidErr()
}
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return false, internalErr()
}
clientType := string(ClientTypeFrom(ctx))
if _, _, err := r.deps.AuthDeliveryReports.ReportMissingCode(ctx, domain.AuthMissingCodeReportRequest{
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
Phone: req.PhoneNumber, PhoneCodeHash: req.PhoneCodeHash,
MNC: req.Mnc, CreatedAt: r.clock.Now(),
}); err != nil {
switch {
case errors.Is(err, domain.ErrPhoneCodeExpired):
return false, phoneCodeExpiredErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid),
errors.Is(err, domain.ErrAuthDeliveryReportInvalid):
return false, phoneCodeInvalidErr()
case errors.Is(err, domain.ErrAuthDeliveryRateLimited):
return false, floodWaitErr(60)
default:
return false, internalErr()
}
}
return true, nil
}
func tgSentCode(hash string) tg.AuthSentCodeClass {
return tgSentCodeWithLength(hash, devCodeLength)
}
@ -521,13 +554,6 @@ func tgEmailSentCode(hash, emailPattern string, length int, resetAvailable bool)
}
}
func tgEmailSetupRequiredSentCode(hash string) tg.AuthSentCodeClass {
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeSetUpEmailRequired{},
PhoneCodeHash: hash,
}
}
// loginEmailResetAvailabilityChecker lets tgSentCodeForHash ask whether
// auth.resetLoginEmail could actually succeed right now, so the client is
// never shown a "Can't access this email?" escape hatch it cannot use (see
@ -536,6 +562,18 @@ type loginEmailResetAvailabilityChecker interface {
LoginEmailResetAvailable() bool
}
func (r *Router) loginEmailResetAvailable() bool {
checker, ok := r.deps.Auth.(loginEmailResetAvailabilityChecker)
return ok && checker.LoginEmailResetAvailable()
}
func tgEmailSetupRequiredSentCode(hash string) tg.AuthSentCodeClass {
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeSetUpEmailRequired{},
PhoneCodeHash: hash,
}
}
func (r *Router) tgSentCodeForHash(ctx context.Context, hash string) (tg.AuthSentCodeClass, error) {
if r.deps.Auth == nil {
return tgSentCode(hash), nil
@ -551,11 +589,7 @@ func (r *Router) tgSentCodeForHash(ctx context.Context, hash string) (tg.AuthSen
case domain.AuthCodeDeliverySMS:
return tgSMSSentCode(hash, delivery.Length), nil
case domain.AuthCodeDeliveryEmail:
resetAvailable := false
if checker, ok := r.deps.Auth.(loginEmailResetAvailabilityChecker); ok {
resetAvailable = checker.LoginEmailResetAvailable()
}
return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length, resetAvailable), nil
return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length, r.loginEmailResetAvailable()), nil
case domain.AuthCodeDeliveryEmailSetupRequired:
return tgEmailSetupRequiredSentCode(hash), nil
default:
@ -600,7 +634,7 @@ func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, needSignUp
}
r.bindSessionUser(ctx, u.ID)
r.pushSignInServiceNotificationToOthers(ctx, u)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
return &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)}, nil
}
func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeRequest) (tg.AuthSentCodeClass, error) {
@ -677,7 +711,8 @@ func (r *Router) onAuthResetAuthorizations(ctx context.Context) (bool, error) {
for _, a := range deleted {
r.revokeAuthKeySessions(a.AuthKeyID)
_ = r.clearAuthKeyState(ctx, a.AuthKeyID)
// P1 修复:撤销其它会话同样销毁其 auth_key,级联 discard 该设备绑定的活跃密聊并通知对端。
// 撤销其它会话会删除其业务 authorization;协议 key 保留用于让客户端
// 重连后取得 AUTH_KEY_UNREGISTERED。密聊仍按设备授权边界 discard 并通知对端。
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(a.AuthKeyID), userID)
}
return true, nil
@ -708,7 +743,7 @@ func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheck
if err != nil {
return nil, internalErr()
}
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
return &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)}, nil
}
func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPasswordRecovery, error) {
@ -749,7 +784,7 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP
if err != nil {
return nil, internalErr()
}
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
return &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)}, nil
}
func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (bool, error) {
@ -880,7 +915,7 @@ func (r *Router) onAuthFinishPasskeyLogin(ctx context.Context, req *tg.AuthFinis
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
return &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)}, nil
}
func emailVerificationCode(v tg.EmailVerificationClass) string {
@ -911,7 +946,7 @@ func (r *Router) onAuthImportBotAuthorization(ctx context.Context, req *tg.AuthI
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
return &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)}, nil
}
// onAuthSignUp 处理 auth.signUp:创建用户并绑定授权。
@ -925,7 +960,7 @@ func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (t
}
r.bindSessionUser(ctx, u.ID)
r.enqueueLoginMessageBootstrap(ctx, loginMessage)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
return &tg.AuthAuthorization{User: r.tgSelfUserWithUsernames(ctx, u)}, nil
}
// onAuthLogOut 处理 auth.logOut:解绑当前 auth_key 的授权。
@ -948,8 +983,8 @@ func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) {
r.presence.clearSession(key)
}
}
// P1 修复:登出销毁本设备 perm auth_key 后,级联 discard 其绑定的活跃密聊并通知对端
//(否则对端继续往死 auth_key 投递成静默死链)。best-effort,不阻断登出。
// 登出撤销本设备 authorization 后,级联 discard 其绑定的活跃密聊并通知对端
//(否则对端继续往已退出设备投递成静默死链)。best-effort,不阻断登出。
if userErr == nil && userID != 0 {
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(id), userID)
}

View file

@ -12,6 +12,11 @@ import "github.com/iamxvbaba/td/tg"
func rpcAllowedWithoutAuthorization(id uint32) bool {
switch id {
case tg.AuthBindTempAuthKeyRequestTypeID,
// TWeb handles a 401 from a remotely revoked session by sending
// auth.logOut before it clears IndexedDB/local authorization state.
// This cleanup RPC is idempotent when no authorization remains; rejecting
// it with another 401 makes Web repeat its startup/logout cycle forever.
tg.AuthLogOutRequestTypeID,
tg.AuthExportLoginTokenRequestTypeID,
tg.AuthImportLoginTokenRequestTypeID,
tg.AuthAcceptLoginTokenRequestTypeID,
@ -57,7 +62,6 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
tg.HelpGetPeerProfileColorsRequestTypeID,
tg.HelpGetPromoDataRequestTypeID,
tg.HelpGetTermsOfServiceUpdateRequestTypeID,
tg.HelpGetPremiumPromoRequestTypeID,
tg.LangpackGetLanguagesRequestTypeID,
tg.LangpackGetLanguageRequestTypeID,
tg.LangpackGetLangPackRequestTypeID,

View file

@ -0,0 +1,831 @@
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))
}
}
func assertMessagesEnvelopeBotVerificationIcon(t *testing.T, out tg.MessagesMessagesClass, peer domain.Peer, want int64) {
t.Helper()
var users []tg.UserClass
var chats []tg.ChatClass
switch value := out.(type) {
case *tg.MessagesMessages:
users, chats = value.Users, value.Chats
case *tg.MessagesMessagesSlice:
users, chats = value.Users, value.Chats
case *tg.MessagesChannelMessages:
users, chats = value.Users, value.Chats
default:
t.Fatalf("messages envelope = %T, want peer-bearing messages.Messages", out)
}
switch peer.Type {
case domain.PeerTypeUser:
for _, item := range users {
user, ok := item.(*tg.User)
if !ok || user.ID != peer.ID {
continue
}
wire := &tg.User{}
tlRoundTrip(t, user, wire)
if icon, ok := wire.GetBotVerificationIcon(); !ok || icon != want {
t.Fatalf("user %d bot_verification_icon = %d, ok=%v, want %d", peer.ID, icon, ok, want)
}
return
}
case domain.PeerTypeChannel:
for _, item := range chats {
channel, ok := item.(*tg.Channel)
if !ok || channel.ID != peer.ID {
continue
}
wire := &tg.Channel{}
tlRoundTrip(t, channel, wire)
if icon, ok := wire.GetBotVerificationIcon(); !ok || icon != want {
t.Fatalf("channel %d bot_verification_icon = %d, ok=%v, want %d", peer.ID, icon, ok, want)
}
return
}
default:
t.Fatalf("unsupported verification peer %+v", peer)
}
t.Fatalf("messages envelope %T does not carry peer %+v", out, peer)
}
// 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))
}
}
// TestPeerSettingsUserPreservesBotVerificationOnLayer228Bit covers the chat-open
// race behind a disappearing badge. Official clients merge messages.peerSettings
// users into the same peer cache as users.getFullUser; therefore the auxiliary
// User must carry the same flags2.14 value regardless of which RPC arrives last.
func TestPeerSettingsUserPreservesBotVerificationOnLayer228Bit(t *testing.T) {
f := newBotVerificationFixture(t, newFakeBotVerifications())
f.enableVerifier(f.bot.ID, 8800011, true)
ownerCtx := WithUserID(context.Background(), f.owner.ID)
peer := &tg.InputPeerUser{UserID: f.target.ID, AccessHash: f.target.AccessHash}
peerSettingsUser := func() *tg.User {
t.Helper()
out, err := f.router.onMessagesGetPeerSettings(ownerCtx, peer)
if err != nil {
t.Fatalf("get peer settings: %v", err)
}
if len(out.Users) != 1 {
t.Fatalf("get peer settings returned %d users, want 1", len(out.Users))
}
wire := &tg.User{}
tlRoundTrip(t, out.Users[0].(*tg.User), wire)
return wire
}
plain := peerSettingsUser()
if _, ok := plain.GetBotVerificationIcon(); ok {
t.Fatalf("unmarked peer settings user carries an icon: %+v", plain)
}
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Stable in chat cache")); err != nil || !ok {
t.Fatalf("grant = %v,%v, want true,nil", ok, err)
}
// Exercise both possible response orders. Neither fullUser -> peerSettings nor
// peerSettings -> fullUser may turn the marked peer back into an unmarked one.
markedFull, _ := getFullUserProjection(t, f.router, f.owner.ID, f.target)
fullMark, ok := markedFull.GetBotVerification()
if !ok || fullMark.Icon != 8800011 {
t.Fatalf("userFull bot_verification = %+v, ok=%v, want icon 8800011", fullMark, ok)
}
marked := peerSettingsUser()
icon, ok := marked.GetBotVerificationIcon()
if !ok || icon != 8800011 {
t.Fatalf("peer settings user bot_verification_icon = %d, ok=%v, want 8800011", icon, ok)
}
assertFlagBitDelta(t, "peerSettings.user", plain.Flags2, marked.Flags2, 14)
marked = peerSettingsUser()
markedFull, _ = getFullUserProjection(t, f.router, f.owner.ID, f.target)
icon, ok = marked.GetBotVerificationIcon()
fullMark, fullOK := markedFull.GetBotVerification()
if !ok || icon != 8800011 || !fullOK || fullMark.Icon != icon {
t.Fatalf("reverse order drift: peer icon=%d ok=%v full=%+v ok=%v", icon, ok, fullMark, fullOK)
}
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 revoked := peerSettingsUser(); revoked.Flags2.Has(14) {
t.Fatalf("revoked peer settings user still carries flags2.14: %032b", uint32(revoked.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)
}
}
// TestOpeningChatMessageLookupsKeepBotVerificationIcons covers the supplemental
// message lookups official clients issue while opening a chat. These responses
// update the same peer cache as messages.getDialogs, so returning an unstamped
// user/channel here makes a visible badge disappear until the dialogs response is
// loaded again.
func TestOpeningChatMessageLookupsKeepBotVerificationIcons(t *testing.T) {
t.Run("private messages.getMessages", func(t *testing.T) {
const (
viewerID = int64(1000000001)
targetID = int64(1000000002)
iconID = int64(8800010)
)
verify := newFakeBotVerifications()
peer := domain.Peer{Type: domain.PeerTypeUser, ID: targetID}
verify.marks[peer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: peer,
IconDocumentID: iconID,
Description: "Verified by Acme Trust",
}
r := New(Config{}, Deps{
Messages: &captureMessages{list: domain.MessageList{
Messages: []domain.Message{{
ID: 7,
OwnerUserID: viewerID,
Peer: peer,
From: peer,
Date: 1700000000,
Body: "reply source",
}},
Count: 1,
}},
Users: mapUsersService{users: map[int64]domain.User{
viewerID: {ID: viewerID, FirstName: "Viewer"},
targetID: {ID: targetID, FirstName: "Target"},
}},
BotVerifications: verify,
}, zaptest.NewLogger(t), clock.System)
result, err := r.onMessagesGetMessages(
WithUserID(context.Background(), viewerID),
[]tg.InputMessageClass{&tg.InputMessageID{ID: 7}},
)
if err != nil {
t.Fatalf("messages.getMessages: %v", err)
}
box := result.(*tg.MessagesMessages)
if len(box.Users) != 1 {
t.Fatalf("users = %d, want target user", len(box.Users))
}
user := &tg.User{}
tlRoundTrip(t, box.Users[0].(*tg.User), user)
if icon, ok := user.GetBotVerificationIcon(); !ok || icon != iconID {
t.Fatalf("opening-chat user bot_verification_icon = %d, ok=%v, want %d", icon, ok, iconID)
}
})
t.Run("channel channels.getMessages", func(t *testing.T) {
const iconID = int64(8800011)
f := newBotVerificationFixture(t, newFakeBotVerifications())
group := f.botVerificationGroup(t, "Verified Group")
sent, err := f.router.onMessagesSendMessage(
WithUserID(context.Background(), f.owner.ID),
&tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
Message: "pinned source",
RandomID: 8800011,
},
)
if err != nil {
t.Fatalf("send channel message: %v", err)
}
messageID := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message).ID
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: group.ID}
f.verify.marks[peer] = domain.CustomVerification{
VerifierBotID: f.bot.ID,
Peer: peer,
IconDocumentID: iconID,
Description: "Verified by Acme Trust",
}
result, err := f.router.onChannelsGetMessages(
WithUserID(context.Background(), f.owner.ID),
&tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: group.ID, AccessHash: group.AccessHash},
ID: []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}},
},
)
if err != nil {
t.Fatalf("channels.getMessages: %v", err)
}
box := result.(*tg.MessagesMessages)
if len(box.Chats) != 1 {
t.Fatalf("chats = %d, want target channel", len(box.Chats))
}
channel := &tg.Channel{}
tlRoundTrip(t, box.Chats[0].(*tg.Channel), channel)
if icon, ok := channel.GetBotVerificationIcon(); !ok || icon != iconID {
t.Fatalf("opening-chat channel bot_verification_icon = %d, ok=%v, want %d", icon, ok, iconID)
}
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1036,6 +1036,7 @@ func (r *Router) tgBotInlineResults(ctx context.Context, viewerUserID int64, in
out.Users = append(out.Users, r.tgUser(u))
}
}
r.applyPeerReadModels(ctx, viewerUserID, out.Users, nil)
return out
}

View file

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

View file

@ -634,9 +634,9 @@ func (d *channelFanoutDispatcher) Enqueue(reqCtx context.Context, job channelFan
d.releaseQueuedJob(job)
}
d.dropped.Add(1)
if job.scope != channelFanoutMembers || job.pts <= 0 {
// 当前所有 enqueue 入口均为 members + durable pts;若未来新增其它 scope,必须先
// 定义其 overflow 恢复面,不能误把 viewer-only/no-pts 更新伪装成 channel nudge。
if job.scope != channelFanoutMembers && job.scope != channelFanoutMessageBox || job.pts <= 0 {
// 只有 durable member/message-box payload 可折叠为 channel nudge;viewer-only/no-pts
// 更新没有 difference 恢复契约,不能伪装成 channel PTS 水位。
d.log.Error("channel fanout queue full for non-coalescible job; overflow contract violated",
zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts), zap.Int("scope", int(job.scope)))
d.enqueueMu.RUnlock()
@ -867,7 +867,7 @@ func (r *Router) runChannelFanoutOverflowNudge(ctx context.Context, channelID in
if r.deps.Sessions == nil || channelID == 0 || pts <= 0 {
return true
}
return r.nudgeBeyondCapChannelMembers(ctx, channelID, pts, nil)
return r.nudgeBeyondCapChannelMessageAudience(ctx, channelID, pts, nil)
}
// runChannelFanoutJob 执行一条 fan-out:与同步 pushChannelUpdatesWithScope 等价,区别是
@ -918,6 +918,8 @@ func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob)
// 仅对会推进客户端 channel PtsWaiter 的真实 payload(members scope + 带 channel pts)做。
if job.scope == channelFanoutMembers && job.pts > 0 {
r.nudgeBeyondCapChannelMembers(pushCtx, job.channelID, job.pts, seen)
} else if job.scope == channelFanoutMessageBox && job.pts > 0 {
r.nudgeBeyondCapChannelMessageAudience(pushCtx, job.channelID, job.pts, seen)
}
}
@ -953,6 +955,23 @@ func channelMessageFanoutOwnerIDs(res domain.SendChannelMessageResult, extraUser
// channelMessagesFanoutOwnerIDs 同上,但取多条结果(批量转发汇成一个 job)的 owner id 并集。
func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, extraUserIDs []int64) []int64 {
userIDs, _ := channelMessagesFanoutPeerRefs(results, extraUserIDs)
return peerIDMapKeys(userIDs)
}
func channelMessagesFanoutUsernamePeers(results []domain.SendChannelMessageResult, extraUserIDs []int64) []domain.Peer {
userIDs, channelIDs := channelMessagesFanoutPeerRefs(results, extraUserIDs)
peers := make([]domain.Peer, 0, len(userIDs)+len(channelIDs))
for userID := range userIDs {
peers = append(peers, domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
for channelID := range channelIDs {
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
}
return peers
}
func channelMessagesFanoutPeerRefs(results []domain.SendChannelMessageResult, extraUserIDs []int64) (map[int64]struct{}, map[int64]struct{}) {
userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs)+4)
channelIDs := make(map[int64]struct{})
for _, id := range extraUserIDs {
@ -964,7 +983,7 @@ func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, ex
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
}
return peerIDMapKeys(userIDs)
return userIDs, channelIDs
}
// enqueueChannelMessageFanout 异步 fan-out 单条频道消息并预热跨 viewer 投影(「频道里出现一条新消息」
@ -974,11 +993,14 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
r.enqueueBotAPIChannelMessageUpdate(ctx, originUserID, res)
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(res, extraUserIDs)
usernamePeers := channelMessagesFanoutUsernamePeers([]domain.SendChannelMessageResult{res}, extraUserIDs)
var usernames map[domain.Peer][]domain.Username
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
// privacy bot 在 send 时被 SkipDeliveryUserIDs 排除(命令/@/回复以外的消息不可见)。
@ -989,7 +1011,7 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
if _, skipped := skip[viewerUserID]; skipped {
return nil
}
return r.channelMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, 0, fanoutCache)
return r.channelMessageUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, res, 0, fanoutCache, usernames)
})
}
@ -1028,6 +1050,23 @@ func skipDeliverySet(ids []int64) map[int64]struct{} {
// ServiceEvent/ServiceMessage 仅 ServiceEvent.Pts!=0 时收,对应 todo 编辑的服务消息第二容器),使预热
// owner 集与 build 实际下发的 Users 集恰好一致——多收只会无害多预热,但镜像门控让等价测试最紧。
func channelEditMessageFanoutOwnerIDs(res domain.EditChannelMessageResult) []int64 {
userIDs, _ := channelEditMessageFanoutPeerRefs(res)
return peerIDMapKeys(userIDs)
}
func channelEditMessageFanoutUsernamePeers(res domain.EditChannelMessageResult) []domain.Peer {
userIDs, channelIDs := channelEditMessageFanoutPeerRefs(res)
peers := make([]domain.Peer, 0, len(userIDs)+len(channelIDs))
for userID := range userIDs {
peers = append(peers, domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
for channelID := range channelIDs {
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
}
return peers
}
func channelEditMessageFanoutPeerRefs(res domain.EditChannelMessageResult) (map[int64]struct{}, map[int64]struct{}) {
userIDs := make(map[int64]struct{}, 4)
channelIDs := make(map[int64]struct{})
if res.Event.Pts != 0 {
@ -1038,7 +1077,7 @@ func channelEditMessageFanoutOwnerIDs(res domain.EditChannelMessageResult) []int
collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs)
}
return peerIDMapKeys(userIDs)
return userIDs, channelIDs
}
// enqueueChannelEditMessageFanout 异步 fan-out 一条频道编辑并预热跨 viewer 投影(editMessage/geolive/
@ -1055,14 +1094,17 @@ func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUser
r.enqueueBotAPIChannelEditMessageUpdate(ctx, originUserID, res)
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelEditMessageFanoutOwnerIDs(res)
usernamePeers := channelEditMessageFanoutUsernamePeers(res)
var usernames map[domain.Peer][]domain.Username
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, nudgePts, res.Recipients,
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, nudgePts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelEditMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache)
return r.channelEditMessageUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, res, fanoutCache, usernames)
})
}
@ -1073,13 +1115,16 @@ func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID,
r.enqueueBotAPIChannelMessagesUpdate(ctx, originUserID, results)
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessagesFanoutOwnerIDs(results, extraUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, channelID, pts, recipients,
usernamePeers := channelMessagesFanoutUsernamePeers(results, extraUserIDs)
var usernames map[domain.Peer][]domain.Username
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, channelID, pts, recipients,
int64(len(results))*(64<<10),
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelMessagesUpdatesWithPeerCache(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache)
return r.channelMessagesUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache, usernames)
})
}
@ -1136,3 +1181,81 @@ func (r *Router) nudgeBeyondCapChannelMembers(ctx context.Context, channelID int
}
return ctx.Err() == nil
}
// nudgeBeyondCapChannelMessageAudience extends member recovery to users with an
// unexpired public-channel short-poll subscription. Subscribers are selected
// first (normally a tiny set), then the remaining bounded capacity is filled
// with joined members. One authoritative batched audience check prevents stale
// runtime indexes from leaking even the channel id/pts to revoked viewers.
func (r *Router) nudgeBeyondCapChannelMessageAudience(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) bool {
if r.deps.Sessions == nil || channelID == 0 || pts <= 0 {
return true
}
if r.deps.Channels == nil {
return r.nudgeBeyondCapChannelMembers(ctx, channelID, pts, delivered)
}
audience, ok := r.deps.Channels.(ChannelMessageAudienceService)
if !ok {
return r.nudgeBeyondCapChannelMembers(ctx, channelID, pts, delivered)
}
limit := r.channelNudgeMaxTargets()
excluded := make(map[int64]struct{}, len(delivered)+16)
for userID := range delivered {
excluded[userID] = struct{}{}
}
candidates := make([]int64, 0, min(limit, 64))
if subscriptions, ok := r.deps.Sessions.(ChannelSubscriptionProvider); ok {
for _, userID := range subscriptions.OnlineChannelSubscriberUserIDsExcluding(channelID, excluded, limit) {
if userID == 0 {
continue
}
excluded[userID] = struct{}{}
candidates = append(candidates, userID)
if len(candidates) >= limit {
break
}
}
}
if len(candidates) < limit {
if members, ok := r.deps.Sessions.(ChannelNudgeProvider); ok {
for _, userID := range members.OnlineChannelMemberUserIDsExcluding(channelID, excluded, limit-len(candidates)) {
if userID == 0 {
continue
}
excluded[userID] = struct{}{}
candidates = append(candidates, userID)
}
}
}
if len(candidates) == 0 {
return true
}
targets, err := audience.FilterMessageAudienceIDs(ctx, channelID, candidates)
if err != nil {
r.log.Warn("channel message audience nudge authorization failed",
zap.Int64("channel_id", channelID), zap.Int("pts", pts), zap.Error(err))
return false
}
if len(targets) == 0 {
return true
}
date := int(r.clock.Now().Unix())
tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID}
tooLong.SetPts(pts)
updates := &tg.Updates{
Updates: []tg.UpdateClass{tooLong},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
for _, userID := range targets {
select {
case <-ctx.Done():
return false
default:
}
r.pushUserUpdates(ctx, userID, updates)
}
return true
}

View file

@ -346,8 +346,9 @@ func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewe
// 回退,prefetch 同步执行)。锁定 edit 路径接入了 O(owner) 预热而非逐 viewer 投影。
func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) {
users := &prefetchRecordingUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{}}}
registry := newFakeUsernameRegistry()
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs, Users: users}, zaptest.NewLogger(t), clock.System)
r := New(Config{}, Deps{Sessions: cs, Users: users, Usernames: registry}, zaptest.NewLogger(t), clock.System)
res := editFanoutTestResult(5, 6)
r.enqueueChannelEditMessageFanout(context.Background(), 5, res)
@ -367,6 +368,9 @@ func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) {
t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", users.gotOwnerIDs, want)
}
}
if registry.batchCalls != 1 || registry.peerCalls != 0 {
t.Fatalf("username registry reads = batch %d / peer %d, want one prefetch for all viewers", registry.batchCalls, registry.peerCalls)
}
}
// nudgeSessions 在 captureSessions 基础上实现 ChannelNudgeProvider 并按 user 记录最近一次推送,

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"time"
"go.uber.org/zap"
@ -9,6 +10,7 @@ import (
)
const channelMembershipSyncPageSize = domain.MaxSynchronousChannelDialogFanout
const publicChannelSubscriptionTTL = 75 * time.Second
func (r *Router) trackChannelInterest(ctx context.Context, userID int64, channelIDs ...int64) {
if userID == 0 || r.deps.Sessions == nil {
@ -37,6 +39,25 @@ func (r *Router) clearChannelInterest(ctx context.Context, userID int64) {
r.trackChannelInterest(ctx, userID)
}
func (r *Router) refreshPublicChannelSubscription(ctx context.Context, userID, channelID int64) {
if userID == 0 || channelID == 0 || r.deps.Sessions == nil {
return
}
provider, ok := r.deps.Sessions.(ChannelSubscriptionProvider)
if !ok {
return
}
rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx)
if !ok {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
provider.RefreshChannelSubscription(rawAuthKeyID, sessionID, userID, channelID, publicChannelSubscriptionTTL)
}
func (r *Router) syncSessionChannelMemberships(ctx context.Context, userID int64) {
if userID == 0 || r.deps.Sessions == nil || r.deps.Channels == nil {
return

View file

@ -17,5 +17,8 @@ const (
const (
channelFanoutMembers channelFanoutScope = iota
channelFanoutViewers
// channelFanoutMessageBox is the durable channel message-box audience:
// online members plus users with an unexpired public short-poll subscription.
channelFanoutMessageBox
channelFanoutExplicit
)

View file

@ -116,7 +116,7 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne
chats = append(chats, tgChannelChatForView(userID, view))
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
r.applyPeerReadModels(ctx, userID, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
@ -144,11 +144,13 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
view.State.NotifySettings = &copy
}
}
return &tg.MessagesChatFull{
out := &tg.MessagesChatFull{
FullChat: tgCommunityFull(view),
Chats: tgCommunityHydratedChats(userID, view),
Users: tgUsers(view.Users),
}, nil
}
r.applyPeerReadModels(ctx, userID, out.Users, out.Chats)
return out, nil
}
if errors.Is(communityErrValue, domain.ErrCommunityPrivate) {
return nil, communityErr(communityErrValue)
@ -172,15 +174,17 @@ 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)
users := r.tgUsersForIDs(ctx, userID, cached.userIDs)
r.applyPeerReadModels(ctx, userID, users, chats)
return &tg.MessagesChatFull{
FullChat: &full,
Chats: chats,
Users: r.tgUsersForIDs(ctx, userID, cached.userIDs),
Users: users,
}, nil
}
view, err := r.channelFullReadView(ctx, userID, input)
@ -220,12 +224,16 @@ 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)
users := r.tgUsersForIDs(ctx, userID, userIDs)
r.applyPeerReadModels(ctx, userID, users, chats)
return &tg.MessagesChatFull{
FullChat: full,
Chats: chats,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
Users: users,
}, nil
}
@ -317,11 +325,13 @@ func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSen
chats = append(chats, tgChannels(userID, extras)...)
}
}
return &tg.ChannelsSendAsPeers{
out := &tg.ChannelsSendAsPeers{
Peers: peers,
Chats: chats,
Users: r.tgUsersForIDs(ctx, userID, []int64{userID}),
}, nil
}
r.applyPeerReadModels(ctx, userID, out.Users, out.Chats)
return out, nil
}
func (r *Router) applyPendingJoinRequestsToFullChannel(ctx context.Context, full *tg.ChannelFull, channelID int64, userIDs []int64) []int64 {

View file

@ -25,6 +25,7 @@ func (r *Router) onChannelsGetLeftChannels(ctx context.Context, offset int) (tg.
for _, item := range list.Channels {
chats = append(chats, tgChannelChat(userID, item.Channel, &item.Self))
}
r.applyUsernamesToPeerObjects(ctx, nil, chats)
if len(chats) == 0 && list.Count > 0 {
return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil
}
@ -56,6 +57,7 @@ func (r *Router) onChannelsGetInactiveChannels(ctx context.Context) (*tg.Message
dates = append(dates, date)
chats = append(chats, tgChannelChatMin(userID, channel))
}
r.applyUsernamesToPeerObjects(ctx, nil, chats)
return &tg.MessagesInactiveChats{Dates: dates, Chats: chats, Users: []tg.UserClass{}}, nil
}
@ -71,5 +73,7 @@ func (r *Router) onChannelsGetGroupsForDiscussion(ctx context.Context) (tg.Messa
if err != nil {
return nil, channelInvalidErr(err)
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
chats := tgChannels(userID, channels)
r.applyUsernamesToPeerObjects(ctx, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}

View file

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

View file

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

View file

@ -29,6 +29,10 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return nil, err
}
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
memberIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, memberIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
r.log.Debug("messages.createChat resolved users",
zap.Int("input_users", len(req.Users)),
@ -84,7 +88,7 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
})
}
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
}
func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) {
@ -145,6 +149,7 @@ func (r *Router) onMessagesGetChats(ctx context.Context, ids []int64) (tg.Messag
}
}
}
r.applyUsernamesToPeerObjects(ctx, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
@ -611,7 +616,7 @@ func (r *Router) enqueueChannelWallpaperFanout(ctx context.Context, originUserID
}
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(sendRes, nil)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
@ -621,25 +626,6 @@ func (r *Router) enqueueChannelWallpaperFanout(ctx context.Context, originUserID
})
}
func (r *Router) onMessagesToggleNoForwards(ctx context.Context, req *tg.MessagesToggleNoForwardsRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
channel, err := r.deps.Channels.SetNoForwards(ctx, userID, channelID, req.Enabled)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *tg.MessagesSetChatAvailableReactionsRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()

View file

@ -28,13 +28,17 @@ func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg
if err != nil {
return nil, internalErr()
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
chats := tgChannels(userID, channels)
r.applyUsernamesToPeerObjects(ctx, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
chats := tgChannels(userID, channels)
r.applyUsernamesToPeerObjects(ctx, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onChannelsDeleteParticipantHistory(ctx context.Context, req *tg.ChannelsDeleteParticipantHistoryRequest) (*tg.MessagesAffectedHistory, error) {
@ -110,6 +114,7 @@ func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.Channel
if len(users) == 0 {
return nil, peerIDInvalidErr()
}
r.applyPeerReadModels(ctx, userID, users, nil)
return users[0], nil
}
@ -199,7 +204,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 +242,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,
@ -311,7 +316,27 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
if err != nil {
return nil, err
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix()))
// Authorize before evaluating target privacy, otherwise a non-admin could
// probe whether a target permits invites.
view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID)
if err != nil {
return nil, channelInviteErr(err)
}
if !view.Self.CanInviteUsers(view.Channel) {
return nil, channelInviteErr(domain.ErrChannelAdminRequired)
}
userIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, userIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
if len(userIDs) == 0 {
return &tg.MessagesInvitedUsers{
Updates: emptyInvitedUsersUpdates(date),
MissingInvitees: missingInvitees,
}, nil
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, date)
if err != nil {
return nil, channelInviteErr(err)
}
@ -322,7 +347,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
})
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
}
func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) {
@ -577,7 +602,7 @@ func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetA
events := tgChannelAdminLogEvents(userID, res.Events)
chats := []tg.ChatClass{tgChannelChatMin(userID, res.Channel)}
users := r.channelAdminLogUsers(ctx, userID, res.Events)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, chats)
r.applyPeerReadModels(ctx, userID, users, chats)
return &tg.ChannelsAdminLogResults{
Events: events,
Chats: chats,

View file

@ -100,7 +100,9 @@ func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSear
return nil, channelInvalidErr(err)
}
history = r.enrichChannelHistory(ctx, userID, history)
return tgChannelSearchPostsMessages(userID, history), nil
result := tgChannelSearchPostsMessages(userID, history)
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
func validateChannelSearchPostsRequest(req *tg.ChannelsSearchPostsRequest) error {
@ -293,11 +295,13 @@ func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetM
messages = append(messages, &tg.MessageEmpty{ID: id})
}
}
return &tg.MessagesMessages{
result := &tg.MessagesMessages{
Messages: messages,
Chats: tgChannels(userID, []domain.Channel{history.Channel}),
Users: r.tgUsersForViewer(userID, history.Users), // viewer 补拉自己的消息(含置顶)须带 self
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) {
@ -335,13 +339,13 @@ func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsD
if res.Event.Pts != 0 {
// 删除 fan-out 异步化(设计 Phase 0)。channelDeleteMessagesUpdates 是纯 CPU 构建
// (不碰 PG、不取 ctx),async 无竞态;同 channel 串行保 pts 单调。
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
})
// 被删 broadcast post 的讨论组转发根级联删除同样要让讨论组成员收敛。
for _, cascade := range res.DiscussionDeletes {
cascade := cascade
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event)
})
}
@ -350,6 +354,24 @@ func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsD
return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil
}
// NotifyModerationChannelDeletion performs the online accelerator for a
// server-authority deletion already committed by the moderation action worker.
// Durable channel update events remain the offline recovery source.
func (r *Router) NotifyModerationChannelDeletion(ctx context.Context, res domain.DeleteChannelMessagesResult) {
if r == nil || res.Event.Pts == 0 {
return
}
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, domain.OfficialSystemUserID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
})
for _, cascade := range res.DiscussionDeletes {
cascade := cascade
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, domain.OfficialSystemUserID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event)
})
}
}
func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil
@ -376,16 +398,20 @@ func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDe
return nil, channelDeleteErr(err)
}
if res.Event.Pts == 0 {
event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID)
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event)
r.pushUserUpdates(ctx, userID, updates)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID)
if res.AvailableMinChanged {
// updateChannelAvailableMessages is an absolute owner-local boundary
// with no account/channel pts. Other online sessions consume it
// immediately; future cold/offline sessions discover the same
// boundary through account/channel difference recovery.
r.pushUserUpdates(ctx, userID, updates)
}
return updates, nil
}
pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates {
out := r.channelDeleteMessagesUpdates(userID, batch.Channel, batch.Event)
// 每批 fan-out 异步化;批次按 pts 递增顺序入同一 channel 分片 → FIFO 保单调。
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, batch.Channel.ID, batch.Event.Pts, batch.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, batch.Channel.ID, batch.Event.Pts, batch.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, batch.Channel, batch.Event)
})
return out

View file

@ -174,6 +174,7 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
@ -193,7 +194,14 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
if err != nil {
t.Fatalf("send channel message: %v", err)
}
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
newChannelUpdate := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
msg := newChannelUpdate.Message.(*tg.Message)
clearSince := int(time.Now().Unix()) - 1
stateBefore, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
pushesBefore := len(sessions.pushedUserIDs())
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID,
@ -202,42 +210,133 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
t.Fatalf("delete channel history local: %v", err)
}
updates, ok := cleared.(*tg.Updates)
if !ok || len(updates.Updates) != 2 {
t.Fatalf("clear response = %T %+v, want available update plus pts bookkeeping", cleared, cleared)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("clear response = %T %+v, want one no-pts available update", cleared, cleared)
}
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID {
t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID)
}
// updateChannelAvailableMessages 不带账号 pts,事件占用的 pts 槽位
// 必须用空 updateDeleteMessages 显式同步给客户端。
bookkeeping, ok := updates.Updates[1].(*tg.UpdateDeleteMessages)
if !ok || len(bookkeeping.Messages) != 0 || bookkeeping.Pts <= 0 || bookkeeping.PtsCount != 1 {
t.Fatalf("clear bookkeeping = %#v, want empty updateDeleteMessages carrying the account pts step", updates.Updates[1])
stateAfter, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state after clear: %v", err)
}
if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("account pts advanced on local channel clear: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
}
pushed := sessions.snapshot()
if pushed.userID != owner.ID {
t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Updates) != 2 {
t.Fatalf("pushed clear update = %T %+v, want available update plus pts bookkeeping", pushed.message, pushed.message)
if !ok || len(pushedUpdates.Updates) != 1 {
t.Fatalf("pushed clear update = %T %+v, want one no-pts available update", pushed.message, pushed.message)
}
if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok {
t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0])
}
diff, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0})
if got := len(sessions.pushedUserIDs()) - pushesBefore; got != 1 {
t.Fatalf("clear online pushes = %d, want exactly one owner-session fanout", got)
}
diff, err := updateSvc.GetDifference(ctx, [8]byte{}, owner.ID, stateBefore)
if err != nil {
t.Fatalf("get difference: %v", err)
}
full, ok := diff.(*tg.UpdatesDifference)
if !ok || len(full.OtherUpdates) != 1 {
t.Fatalf("difference = %T %+v, want one other update", diff, diff)
if len(diff.Events) != 0 || diff.State.Pts != stateBefore.Pts {
t.Fatalf("difference after no-pts clear = %+v, want no durable account event", diff)
}
if diffUpdate, ok := full.OtherUpdates[0].(*tg.UpdateChannelAvailableMessages); !ok || diffUpdate.ChannelID != channel.ID || diffUpdate.AvailableMinID != msg.ID {
t.Fatalf("difference update = %#v, want updateChannelAvailableMessages", full.OtherUpdates[0])
offline, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{
Pts: stateBefore.Pts,
Date: clearSince,
})
if err != nil {
t.Fatalf("account difference after offline local clear: %v", err)
}
offlineDiff, ok := offline.(*tg.UpdatesDifference)
if !ok {
t.Fatalf("offline account difference = %T %+v, want updates.difference", offline, offline)
}
var accountAvailable *tg.UpdateChannelAvailableMessages
for _, update := range offlineDiff.OtherUpdates {
if value, ok := update.(*tg.UpdateChannelAvailableMessages); ok {
accountAvailable = value
break
}
}
if accountAvailable == nil ||
accountAvailable.ChannelID != channel.ID ||
accountAvailable.AvailableMinID != msg.ID {
t.Fatalf("offline account updates = %+v, want updateChannelAvailableMessages channel=%d min=%d",
offlineDiff.OtherUpdates, channel.ID, msg.ID)
}
if offlineDiff.State.Pts != stateBefore.Pts || offlineDiff.State.Date < clearSince {
t.Fatalf("offline account state = %+v, want unchanged pts=%d and non-regressing date", offlineDiff.State, stateBefore.Pts)
}
for attempt := 1; attempt <= 2; attempt++ {
channelOffline, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: newChannelUpdate.Pts,
Limit: 100,
})
if err != nil {
t.Fatalf("channel difference attempt %d after offline local clear: %v", attempt, err)
}
channelDiff, ok := channelOffline.(*tg.UpdatesChannelDifferenceEmpty)
if !ok || channelDiff.Pts != newChannelUpdate.Pts {
t.Fatalf("channel difference attempt %d = %T %+v, want empty with unchanged channel pts=%d",
attempt, channelOffline, channelOffline, newChannelUpdate.Pts)
}
}
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs after clear: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs after clear: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogs)
if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Messages) != 1 {
t.Fatalf("dialogs after cold projection = %T %+v, want one anchored dialog/message", enc, enc)
}
dialog := dialogs.Dialogs[0].(*tg.Dialog)
service, ok := dialogs.Messages[0].(*tg.MessageService)
if !ok || dialog.TopMessage != msg.ID || service.ID != msg.ID {
t.Fatalf("cold dialog projection = dialog=%+v message=%T %+v, want history-clear top %d", dialog, dialogs.Messages[0], dialogs.Messages[0], msg.ID)
}
if _, ok := service.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold dialog service action = %T, want messageActionHistoryClear", service.Action)
}
historyReq := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 20,
}
b.Reset()
if err := historyReq.Encode(&b); err != nil {
t.Fatalf("encode get history after clear: %v", err)
}
historyEnc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get history after clear: %v", err)
}
history, ok := historyEnc.(*tg.MessagesChannelMessages)
if !ok || len(history.Messages) != 1 {
t.Fatalf("history after cold projection = %T %+v, want one history-clear marker", historyEnc, historyEnc)
}
historyService, ok := history.Messages[0].(*tg.MessageService)
if !ok || historyService.ID != msg.ID {
t.Fatalf("cold history projection = %T %+v, want service marker %d", history.Messages[0], history.Messages[0], msg.ID)
}
if _, ok := historyService.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold history service action = %T, want messageActionHistoryClear", historyService.Action)
}
stalePushesBefore := len(sessions.pushedUserIDs())
stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID - 1,
@ -246,13 +345,16 @@ func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.
t.Fatalf("stale delete channel history local: %v", err)
}
staleUpdates, ok := stale.(*tg.Updates)
if !ok || len(staleUpdates.Updates) != 2 {
t.Fatalf("stale clear response = %T %+v, want monotonic update plus pts bookkeeping", stale, stale)
if !ok || len(staleUpdates.Updates) != 1 {
t.Fatalf("stale clear response = %T %+v, want monotonic absolute update", stale, stale)
}
staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID {
t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID)
}
if got := len(sessions.pushedUserIDs()); got != stalePushesBefore {
t.Fatalf("stale clear pushed another online update: before=%d after=%d", stalePushesBefore, got)
}
}
func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) {
@ -733,9 +835,11 @@ func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) {
viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
verify := newFakeBotVerifications()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Users: appusers.NewService(userStore),
Channels: channelService,
BotVerifications: verify,
}, zaptest.NewLogger(t), clock.System)
public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Public Search",
@ -745,6 +849,14 @@ func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) {
if err != nil {
t.Fatalf("create public channel: %v", err)
}
const searchPostsIcon = int64(8800021)
searchPostsPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: public.Channel.ID}
verify.marks[searchPostsPeer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: searchPostsPeer,
IconDocumentID: searchPostsIcon,
Description: "Verified search result",
}
if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID,
ChannelID: public.Channel.ID,
@ -818,6 +930,7 @@ func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) {
if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != public.Channel.ID {
t.Fatalf("first result peer = %#v, want public channel %d", first.PeerID, public.Channel.ID)
}
assertMessagesEnvelopeBotVerificationIcon(t, got, searchPostsPeer, searchPostsIcon)
page2 := &tg.ChannelsSearchPostsRequest{
OffsetRate: slice.NextRate,

View file

@ -65,8 +65,8 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
}
ids = append(ids, sent.Message.ID)
}
// 三条置顶:早期、中间、最新(Android 置顶栏循环跳转需要全部三条都可跳)。
pins := []int{ids[4], ids[14], ids[27]}
// 五条置顶:覆盖用户反馈中的真实规模;Android 置顶栏循环跳转需要全部可跳。
pins := []int{ids[4], ids[9], ids[14], ids[20], ids[27]}
for _, id := range pins {
if _, err := channelSvc.UpdatePinnedMessage(ctx, owner.ID, domain.UpdateChannelPinnedMessageRequest{
ChannelID: channelID,
@ -97,12 +97,17 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
}
// ① 打开聊天:MediaDataController.loadPinnedMessages → messages.search filterPinned。
searchEnc := dispatch(&tg.MessagesSearchRequest{
androidPinnedSearch := &tg.MessagesSearchRequest{
Peer: peer,
Q: "",
Filter: &tg.InputMessagesFilterPinned{},
Limit: 40,
})
}
// DrKLO initializes saved_reaction to an empty non-nil ArrayList and its
// serializer consequently emits flags.3 + Vector length 0 on every
// messages.search, including channel filterPinned.
androidPinnedSearch.SetSavedReaction([]tg.ReactionClass{})
searchEnc := dispatch(androidPinnedSearch)
channelMessages, ok := searchEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("pinned search response = %T, want messages.channelMessages", searchEnc)
@ -113,7 +118,10 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
if channelMessages.Count != len(pins) {
t.Fatalf("pinned search count = %d, want %d", channelMessages.Count, len(pins))
}
wantDesc := []int{pins[2], pins[1], pins[0]}
wantDesc := make([]int, len(pins))
for i := range pins {
wantDesc[i] = pins[len(pins)-1-i]
}
for i, raw := range channelMessages.Messages {
msg, ok := raw.(*tg.Message)
if !ok {
@ -128,7 +136,49 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
}
}
// ② 点置顶栏跳最旧 pin:scrollToMessageId → getHistory AROUND(手机 count=20)。
// ② tweb 冷加载:先用 limit=1 取最新 pin,并把 messages.channelMessages.count
// 当作完整置顶数;旧实现把 count 错算成 len(page)+hasMore,即五条只报两条。
twebSearchEnc := dispatch(&tg.MessagesSearchRequest{
Peer: peer,
Q: "",
Filter: &tg.InputMessagesFilterPinned{},
Limit: 1,
})
twebSearch, ok := twebSearchEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("tweb pinned search response = %T, want messages.channelMessages", twebSearchEnc)
}
if len(twebSearch.Messages) != 1 || twebSearch.Count != len(pins) {
t.Fatalf("tweb pinned search messages/count = %d/%d, want 1/%d", len(twebSearch.Messages), twebSearch.Count, len(pins))
}
// limit=0 是官方 count-only 入口:不得为了计数反序列化/返回消息页。
countOnlyEnc := dispatch(&tg.MessagesSearchRequest{
Peer: peer,
Q: "",
Filter: &tg.InputMessagesFilterPinned{},
Limit: 0,
})
countOnly, ok := countOnlyEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("count-only pinned search response = %T, want messages.channelMessages", countOnlyEnc)
}
if len(countOnly.Messages) != 0 || countOnly.Count != len(pins) {
t.Fatalf("count-only pinned search messages/count = %d/%d, want 0/%d", len(countOnly.Messages), countOnly.Count, len(pins))
}
counters, err := r.onMessagesGetSearchCounters(WithUserID(androidClientContext(), member.ID), &tg.MessagesGetSearchCountersRequest{
Peer: peer,
Filters: []tg.MessagesFilterClass{&tg.InputMessagesFilterPinned{}},
})
if err != nil {
t.Fatalf("messages.getSearchCounters(filterPinned): %v", err)
}
if len(counters) != 1 || counters[0].Count != len(pins) {
t.Fatalf("pinned search counters = %+v, want count %d", counters, len(pins))
}
// ③ 点置顶栏跳最旧 pin:scrollToMessageId → getHistory AROUND(手机 count=20)。
const aroundCount = 20
histEnc := dispatch(&tg.MessagesGetHistoryRequest{
Peer: peer,
@ -160,7 +210,7 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
t.Fatalf("around history lacks anchor %d: jump shows MessageNotFound on Android", pins[0])
}
// ③ 本地缺对象补拉:MessagesStorage.loadChatInfo → channels.getMessages。
// ④ 本地缺对象补拉:MessagesStorage.loadChatInfo → channels.getMessages。
// DrKLO 发的是 pre-InputMessage 构造器 #93d7b347(id:Vector<int>),
// 该请求 500 会让客户端把这批 pin 按「已取消置顶」从本地缓存删除。
var legacy bin.Buffer
@ -208,7 +258,7 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
}
}
// ④ chatFull 降级缓存:pinned_msg_id 必须是最新置顶(Android 以它判断是否重拉列表)。
// ⑤ chatFull 降级缓存:pinned_msg_id 必须是最新置顶(Android 以它判断是否重拉列表)。
fullEnc := dispatch(&tg.ChannelsGetFullChannelRequest{
Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash},
})
@ -220,7 +270,7 @@ func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
if !ok {
t.Fatalf("full chat = %T, want channelFull", full.FullChat)
}
if pinnedID, _ := channelFull.GetPinnedMsgID(); pinnedID != pins[2] {
t.Fatalf("channelFull pinned_msg_id = %d, want latest pin %d", pinnedID, pins[2])
if pinnedID, _ := channelFull.GetPinnedMsgID(); pinnedID != pins[len(pins)-1] {
t.Fatalf("channelFull pinned_msg_id = %d, want latest pin %d", pinnedID, pins[len(pins)-1])
}
}

View file

@ -3,7 +3,11 @@ package rpc
import (
"github.com/iamxvbaba/td/tg"
"strings"
apptelemetry "telesrv/internal/app/clienttelemetry"
appmessages "telesrv/internal/app/messages"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
@ -11,6 +15,14 @@ import (
func TestTDesktopPassiveChannelStubs(t *testing.T) {
f := newRPCChannelFixture(t)
r := f.router
moderationReports := memory.NewModerationReportStore()
telemetryEvents := memory.NewClientTelemetryStore()
r.deps.ClientTelemetry = apptelemetry.NewService(telemetryEvents)
r.deps.Moderation = appmoderation.NewService(
moderationReports,
appmoderation.WithMessageReaders(nil, r.deps.Channels),
appmoderation.WithPeerReaders(r.deps.Users, r.deps.Channels),
)
owner := f.user(41, "15550002101", "Owner")
friend := f.user(42, "15550002102", "Friend")
invited := f.user(43, "15550002103", "Invited")
@ -438,6 +450,36 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if ok, err := r.onMessagesReportSpam(ownerCtx, inputPeerChannel(channel)); err != nil || !ok {
t.Fatalf("messages.reportSpam = ok %v err %v, want true nil", ok, err)
}
if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
Peer: inputPeerChannel(channel),
}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_REQUIRED") {
t.Fatalf("desktop messages.report without ids err = %v, want MESSAGE_ID_REQUIRED", err)
}
androidReportOptions, err := r.onMessagesReport(
WithClientInfo(ownerCtx, ClientInfo{
Type: ClientTypeAndroid,
AppVersion: "12.9.0 (69669) pbeta",
}),
&tg.MessagesReportRequest{Peer: inputPeerChannel(channel)},
)
if err != nil {
t.Fatalf("android messages.report initial options: %v", err)
}
if choices, ok := androidReportOptions.(*tg.ReportResultChooseOption); !ok || len(choices.Options) == 0 {
t.Fatalf("android messages.report initial options = %#v, want chooseOption", androidReportOptions)
}
if got := moderationReports.Reports(); len(got) != 1 {
t.Fatalf("android initial report option discovery persisted reports = %+v, want only earlier reportSpam", got)
}
if _, err := r.onMessagesReport(
WithClientInfo(ownerCtx, ClientInfo{Type: ClientTypeAndroid}),
&tg.MessagesReportRequest{
Peer: inputPeerChannel(channel),
Option: []byte("spam"),
},
); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_REQUIRED") {
t.Fatalf("android selected report option without ids err = %v, want MESSAGE_ID_REQUIRED", err)
}
reportOptions, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -470,6 +512,9 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, ok := reported.(*tg.ReportResultReported); !ok {
t.Fatalf("messages.report spam = %#v, want reported", reported)
}
if got := moderationReports.Reports(); len(got) != 2 {
t.Fatalf("moderation reports = %+v, want peer-spam and message reports", got)
}
if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -477,13 +522,6 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") {
t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err)
}
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
Peer: inputPeerChannel(channel),
ID: 1,
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
}); err != nil || !ok {
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
}
if ok, err := r.onMessagesReportMessagesDelivery(ownerCtx, &tg.MessagesReportMessagesDeliveryRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -503,11 +541,45 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err != nil || !ok {
t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err)
}
if events := telemetryEvents.Events(); len(events) != 2 ||
events[0].Kind != domain.ClientTelemetryMessageDelivery ||
events[1].Kind != domain.ClientTelemetryReadMetrics ||
len(moderationReports.Reports()) != 2 {
t.Fatalf("telemetry=%+v moderation=%+v, want separate durable streams",
events, moderationReports.Reports())
}
if ok, err := r.onMessagesReportMusicListen(ownerCtx, &tg.MessagesReportMusicListenRequest{
ID: &tg.InputDocument{ID: 1, AccessHash: 2},
ListenedDuration: 1,
}); err != nil || !ok {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err)
}); err == nil || ok || !strings.Contains(err.Error(), "DOCUMENT_INVALID") {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want DOCUMENT_INVALID", ok, err)
}
if _, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("unseen-ad"),
}); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_INVALID") {
t.Fatalf("unseen sponsored report err=%v, want RANDOM_ID_INVALID", err)
}
impression, err := domain.NewSponsoredMessageImpression(
owner.ID, []byte("ad"),
domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
0, []byte(`{"schema_version":1,"text":"test sponsored message"}`),
time.Now().UTC(), time.Now().UTC().Add(time.Hour),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := moderationReports.CreateSponsoredMessageImpression(ownerCtx, impression); err != nil {
t.Fatal(err)
}
sponsoredOptions, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("ad"),
})
if err != nil {
t.Fatalf("messages.reportSponsoredMessage options: %v", err)
}
if choices, ok := sponsoredOptions.(*tg.ChannelsSponsoredMessageReportResultChooseOption); !ok ||
len(choices.Options) == 0 {
t.Fatalf("sponsored options=%#v, want chooseOption", sponsoredOptions)
}
sponsoredReport, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("ad"),
@ -577,6 +649,13 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil {
t.Fatalf("messages.sendReaction by friend: %v", err)
}
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
Peer: inputPeerChannel(channel),
ID: viewedID,
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
}); err != nil || !ok {
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
}
unreadReactions, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{
Peer: inputPeerChannel(channel),
Limit: 10,
@ -787,28 +866,22 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if !ok || staleEmptyPage.Hash != 0 || len(staleEmptyPage.Tags) != 0 {
t.Fatalf("messages.getSavedReactionTags stale empty hash = %#v, want empty page hash 0", staleEmptyTags)
}
r.deps.Messages = appmessages.NewService(memory.NewMessageStore(), nil)
if _, err := f.users.SetPremiumUntil(ownerCtx, owner.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
t.Fatalf("grant owner premium for saved tag rename: %v", err)
}
updateTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}}
updateTagReq.SetTitle("Work")
if ok, err := r.onMessagesUpdateSavedReactionTag(ownerCtx, updateTagReq); err != nil || !ok {
t.Fatalf("messages.updateSavedReactionTag = ok %v err %v, want true nil", ok, err)
if _, err := r.onMessagesUpdateSavedReactionTag(ownerCtx, updateTagReq); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") {
t.Fatalf("messages.updateSavedReactionTag unassigned err = %v, want REACTION_INVALID", err)
}
globalTags, err := r.onMessagesGetSavedReactionTags(ownerCtx, &tg.MessagesGetSavedReactionTagsRequest{})
if err != nil {
t.Fatalf("messages.getSavedReactionTags global: %v", err)
}
globalPage, ok := globalTags.(*tg.MessagesSavedReactionTags)
if !ok || globalPage.Hash == 0 || len(globalPage.Tags) != 1 {
t.Fatalf("messages.getSavedReactionTags global = %#v, want one hashable tag", globalTags)
}
if emoji, ok := globalPage.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "ok" || globalPage.Tags[0].Title != "Work" || globalPage.Tags[0].Count != 0 {
t.Fatalf("messages.getSavedReactionTags tag = %+v, want ok/Work/count0", globalPage.Tags[0])
}
globalNotModified, err := r.onMessagesGetSavedReactionTags(ownerCtx, &tg.MessagesGetSavedReactionTagsRequest{Hash: globalPage.Hash})
if err != nil {
t.Fatalf("messages.getSavedReactionTags hash: %v", err)
}
if _, ok := globalNotModified.(*tg.MessagesSavedReactionTagsNotModified); !ok {
t.Fatalf("messages.getSavedReactionTags hash = %#v, want notModified", globalNotModified)
if !ok || globalPage.Hash != 0 || len(globalPage.Tags) != 0 {
t.Fatalf("messages.getSavedReactionTags global = %#v, want empty", globalTags)
}
peerTagsAfterUpdate, err := r.onMessagesGetSavedReactionTags(ownerCtx, savedTagsReq)
if err != nil {
@ -831,9 +904,11 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if err != nil {
t.Fatalf("messages.getDefaultTagReactions: %v", err)
}
if got := tagReactions.(*tg.MessagesReactions).Reactions; len(got) != 0 {
t.Fatalf("messages.getDefaultTagReactions = %+v, want empty", got)
defaultPage, ok := tagReactions.(*tg.MessagesReactions)
if !ok || defaultPage.Hash == 0 || len(defaultPage.Reactions) == 0 {
t.Fatalf("messages.getDefaultTagReactions = %#v, want non-empty hashable catalog", tagReactions)
}
r.deps.Messages = nil
// poll 链路已是真实现:对非 poll 消息一律 MESSAGE_ID_INVALID(与官方一致)。
if _, err := r.onMessagesSendVote(ownerCtx, &tg.MessagesSendVoteRequest{
Peer: inputPeerChannel(channel),
@ -886,6 +961,16 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("messages.deletePollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err)
}
verify := newFakeBotVerifications()
r.deps.BotVerifications = verify
const unreadPollIcon = int64(8800024)
unreadPollPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}
verify.marks[unreadPollPeer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: unreadPollPeer,
IconDocumentID: unreadPollIcon,
Description: "Verified poll peer",
}
unreadPollVotes, err := r.onMessagesGetUnreadPollVotes(ownerCtx, &tg.MessagesGetUnreadPollVotesRequest{
Peer: inputPeerChannel(channel),
Limit: 10,
@ -893,6 +978,7 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if err != nil {
t.Fatalf("messages.getUnreadPollVotes: %v", err)
}
assertMessagesEnvelopeBotVerificationIcon(t, unreadPollVotes, unreadPollPeer, unreadPollIcon)
if len(unreadPollVotes.(*tg.MessagesMessages).Messages) != 0 {
t.Fatalf("messages.getUnreadPollVotes = %+v, want empty messages", unreadPollVotes)
}

View file

@ -16,6 +16,7 @@ import (
func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
ctx := context.Background()
sessions := &captureSessions{}
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 92001, Phone: "15550092001", FirstName: "Owner"})
viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 92002, Phone: "15550092002", FirstName: "Viewer"})
@ -26,6 +27,7 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
Users: appusers.NewService(userStore),
Channels: channelService,
Dialogs: dialogService,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Public Preview RPC",
@ -122,8 +124,16 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
if !ok || !historyChat.Left || historyChat.ID != public.Channel.ID {
t.Fatalf("history chat = %T %+v, want left public channel", history.Chats[0], history.Chats[0])
}
readOK, err := r.onChannelsReadHistory(WithUserID(ctx, viewer.ID), &tg.ChannelsReadHistoryRequest{
Channel: input,
MaxID: sent.Message.ID,
})
if err != nil || !readOK {
t.Fatalf("non-member readHistory public preview = %v err=%v, want successful no-op", readOK, err)
}
diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewer.ID), &tg.UpdatesGetChannelDifferenceRequest{
viewerCtx := WithSessionID(WithRawAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{9, 2}), 9202)
diff, err := r.onUpdatesGetChannelDifference(viewerCtx, &tg.UpdatesGetChannelDifferenceRequest{
Channel: input,
Pts: public.Event.Pts,
Limit: 10,
@ -131,9 +141,66 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
if err != nil {
t.Fatalf("non-member getChannelDifference public preview: %v", err)
}
emptyDiff, ok := diff.(*tg.UpdatesChannelDifferenceEmpty)
if !ok || !emptyDiff.Final || emptyDiff.Pts != sent.Event.Pts {
t.Fatalf("channel difference = %T %+v, want empty public preview difference at current pts", diff, diff)
fullDiff, ok := diff.(*tg.UpdatesChannelDifference)
if !ok || !fullDiff.Final || fullDiff.Pts != sent.Event.Pts || len(fullDiff.NewMessages) != 1 {
t.Fatalf("channel difference = %T %+v, want one public preview message", diff, diff)
}
message, ok := fullDiff.NewMessages[0].(*tg.Message)
if !ok || message.ID != sent.Message.ID || message.Message != sent.Message.Body {
t.Fatalf("channel difference message = %T %+v, want sent public post", fullDiff.NewMessages[0], fullDiff.NewMessages[0])
}
if subscribers := sessions.OnlineChannelSubscriberUserIDs(public.Channel.ID, 10); len(subscribers) != 1 || subscribers[0] != viewer.ID {
t.Fatalf("public channel subscribers = %v, want viewer %d", subscribers, viewer.ID)
}
live, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 202,
Message: "public preview live post",
Date: 1700010120,
})
if err != nil {
t.Fatalf("send live public post: %v", err)
}
sessions.clearMessages()
r.enqueueChannelMessageFanout(WithUserID(ctx, owner.ID), owner.ID, live, nil)
if !fanoutHasID(sessions.pushedUserIDs(), viewer.ID) {
t.Fatalf("live public preview fanout users = %v, want viewer %d", sessions.pushedUserIDs(), viewer.ID)
}
liveUpdates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(liveUpdates.Updates) == 0 {
t.Fatalf("live public preview update = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
foundLive := false
for _, update := range liveUpdates.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
if item, ok := newMessage.Message.(*tg.Message); ok && item.ID == live.Message.ID && item.Message == live.Message.Body {
foundLive = true
}
}
if !foundLive {
t.Fatalf("live public preview updates = %+v, want new message %d", liveUpdates.Updates, live.Message.ID)
}
sessions.clearMessages()
if ok := r.runChannelFanoutOverflowNudge(ctx, public.Channel.ID, live.Event.Pts); !ok {
t.Fatal("public preview overflow nudge did not complete")
}
if !fanoutHasID(sessions.pushedUserIDs(), viewer.ID) {
t.Fatalf("public preview overflow nudge users = %v, want viewer %d", sessions.pushedUserIDs(), viewer.ID)
}
nudgeUpdates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(nudgeUpdates.Updates) != 1 {
t.Fatalf("public preview overflow nudge = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
tooLong, ok := nudgeUpdates.Updates[0].(*tg.UpdateChannelTooLong)
if !ok || tooLong.ChannelID != public.Channel.ID {
t.Fatalf("public preview overflow update = %T %+v, want channel %d tooLong", nudgeUpdates.Updates[0], nudgeUpdates.Updates[0], public.Channel.ID)
}
if pts, ok := tooLong.GetPts(); !ok || pts != live.Event.Pts {
t.Fatalf("public preview overflow pts = %d/%v, want %d", pts, ok, live.Event.Pts)
}
domainPeers, err := r.dialogPeersFromInput(WithUserID(ctx, viewer.ID), viewer.ID, []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}})
@ -147,8 +214,12 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
if err != nil {
t.Fatalf("dialog service public preview: %v", err)
}
if len(directPeerDialogs.Dialogs) != 0 || len(directPeerDialogs.ChannelMessages) != 0 || len(directPeerDialogs.Channels) != 0 {
t.Fatalf("direct peer dialogs = %+v, want no public preview dialog/message/channel", directPeerDialogs)
if len(directPeerDialogs.Dialogs) != 1 || len(directPeerDialogs.ChannelMessages) != 0 || len(directPeerDialogs.Channels) != 1 {
t.Fatalf("direct peer dialogs = %+v, want one zero-top public preview bootstrap", directPeerDialogs)
}
directDialog := directPeerDialogs.Dialogs[0]
if directDialog.TopMessage != 0 || !directDialog.ChannelLeft || directDialog.Pts != live.Event.Pts {
t.Fatalf("direct public preview dialog = %+v, want left zero-top bootstrap", directDialog)
}
peerDialogsReq := &tg.MessagesGetPeerDialogsRequest{
@ -166,8 +237,17 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
if !ok {
t.Fatalf("getPeerDialogs response = %T, want peer dialogs", peerDialogsEnc)
}
if len(peerDialogs.Dialogs) != 0 || len(peerDialogs.Messages) != 0 || len(peerDialogs.Chats) != 0 {
t.Fatalf("peer dialogs = %+v, want no public preview dialog/message/channel", peerDialogs)
if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Messages) != 0 || len(peerDialogs.Chats) != 1 {
t.Fatalf("peer dialogs = %+v, want one zero-top public preview bootstrap", peerDialogs)
}
tgDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog)
if !ok || tgDialog.TopMessage != 0 || tgDialog.ReadInboxMaxID != 0 ||
tgDialog.ReadOutboxMaxID != 0 || tgDialog.UnreadCount != 0 {
t.Fatalf("peer dialog = %T %+v, want zero-state dialog", peerDialogs.Dialogs[0], peerDialogs.Dialogs[0])
}
peerDialogChat, ok := peerDialogs.Chats[0].(*tg.Channel)
if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID {
t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0])
}
if _, err := channelService.JoinChannel(ctx, viewer.ID, public.Channel.ID, 1700010120); err != nil {
@ -193,3 +273,44 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
t.Fatalf("joined peer dialog chat = %T %+v, want active channel with left=false", joinedPeerDialogs.Chats[0], joinedPeerDialogs.Chats[0])
}
}
func TestChannelsReadHistoryAllowsSyntheticMonoforumViewers(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 92101, Phone: "15550092101", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
subscriber, err := userStore.Create(ctx, domain.User{AccessHash: 92102, Phone: "15550092102", FirstName: "Subscriber"})
if err != nil {
t.Fatal(err)
}
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore)
parent, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Monoforum Read RPC", Broadcast: true, Date: 1_700_011_000,
})
if err != nil {
t.Fatal(err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, parent.Channel.ID, 0, true)
if err != nil {
t.Fatal(err)
}
mono, err := channelStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
if err != nil {
t.Fatal(err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore), Channels: channels,
}, zaptest.NewLogger(t), clock.System)
for _, userID := range []int64{owner.ID, subscriber.ID} {
ok, err := r.onChannelsReadHistory(WithUserID(ctx, userID), &tg.ChannelsReadHistoryRequest{
Channel: &tg.InputChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash},
MaxID: mono.TopMessageID,
})
if err != nil || !ok {
t.Fatalf("synthetic monoforum read for %d = %v err=%v, want successful no-op", userID, ok, err)
}
}
}

View file

@ -58,6 +58,9 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead
if r.deps.Channels == nil {
return true, nil
}
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
@ -75,6 +78,9 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead
if err != nil {
return false, channelInvalidErr(err)
}
if read.ReadOnly {
return true, nil
}
if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil {
return false, err
}

View file

@ -102,28 +102,66 @@ func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.Channel
return true, nil
}
// onChannelsReorderUsernames rewrites the channel's collectible username order.
// Permissions are the ordinary change_info gate every other channels.* setting
// uses (channelChangeInfoView).
//
// With no username registry wired the handler keeps its historical accept-and-
// ignore answer: the channel then owns exactly one editable username, whose order
// is not expressible, so reporting success is both true and what shipped clients
// already saw.
func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) {
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if len(req.Order) > maxChannelUsernameOrder {
return false, limitInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
_, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.reorderRegistryUsernames(ctx, peer, req.Order); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) {
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if req.Username != "" && !validChannelManagementUsername(req.Username) {
return false, usernameInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
_, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.toggleRegistryUsername(ctx, peer, req.Username, req.Active); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsDeactivateAllUsernames(ctx context.Context, input tg.InputChannelClass) (bool, error) {
if _, _, err := r.channelChangeInfoView(ctx, input); err != nil {
_, view, err := r.channelChangeInfoView(ctx, input)
if err != nil {
return false, err
}
if r.deps.Usernames == nil {
return true, nil
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: view.Channel.ID}
if err := r.deactivateAllRegistryUsernames(ctx, peer); err != nil {
return false, err
}
return true, nil
@ -206,9 +244,21 @@ func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if !view.Channel.Megagroup || view.Channel.Broadcast {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportAntiSpamFalsePositive(
ctx, userID, view.Channel.ID, req.MsgID, r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
@ -239,6 +289,7 @@ func (r *Router) onChannelsGetChannelRecommendations(ctx context.Context, req *t
return nil, channelInvalidErr(err)
}
chats := tgChannels(userID, res.Channels)
r.applyUsernamesToPeerObjects(ctx, nil, chats)
if res.Count > len(chats) {
return &tg.MessagesChatsSlice{Count: res.Count, Chats: chats}, nil
}

View file

@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
@ -12,6 +13,7 @@ import (
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/app/readmodel"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -598,9 +600,17 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"})
channelStore := memory.NewChannelStore()
moderationStore := memory.NewModerationReportStore()
userService := appusers.NewService(userStore)
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Users: userService,
Channels: channelService,
Moderation: appmoderation.NewService(
moderationStore,
appmoderation.WithMessageReaders(nil, channelService),
appmoderation.WithPeerReaders(userService, channelService),
),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Public Team",
@ -621,6 +631,23 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
t.Fatalf("send seed message: %v", err)
}
msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
if ok, err := r.onChannelsReportAntiSpamFalsePositive(
WithUserID(ctx, owner.ID),
&tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID},
); err == nil || ok || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("report anti-spam without native decision = ok %v err %v, want MESSAGE_ID_INVALID", ok, err)
}
antiSpamDecision, err := domain.NewChannelAntiSpamDecision(
channel.ID, msgID, owner.ID,
[]byte(`{"schema_version":1,"source":"native_antispam"}`),
time.Now().UTC(),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := moderationStore.CreateChannelAntiSpamDecision(ctx, antiSpamDecision); err != nil {
t.Fatal(err)
}
okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{
Channel: input,

View file

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

View file

@ -18,6 +18,12 @@ func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error {
}
func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
if len(req.ID) == 0 {
return false, tgerr.New(400, "MESSAGE_ID_REQUIRED")
}
if len(req.ID) > maxChannelReportMessageIDs {
return false, limitInvalidErr()
}
@ -26,12 +32,30 @@ func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsRepor
return false, messageIDInvalidErr()
}
}
if _, _, err := r.channelView(ctx, req.Channel); err != nil {
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
if !view.Channel.Megagroup {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant)
if err != nil {
return false, err
}
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return false, peerIDInvalidErr()
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportChannelSpam(ctx, domain.ModerationChannelSpamReportRequest{
ReporterUserID: userID, ChannelID: view.Channel.ID,
ParticipantUserID: peer.ID, MessageIDs: req.ID,
CreatedAt: r.clock.Now(),
}); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
@ -177,7 +201,7 @@ func (r *Router) onMessagesUnpinAllMessages(ctx context.Context, req *tg.Message
return nil, channelAdminErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelPinnedUpdates(viewerUserID, res)
})
return &tg.MessagesAffectedHistory{
@ -266,27 +290,6 @@ func peerIDsExcept(ids []int64, skipIDs ...int64) []int64 {
type channelFanoutScope int
func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, channelID int64, availableMinID int) domain.UpdateEvent {
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventChannelAvailable,
Date: int(r.clock.Now().Unix()),
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
PtsCount: 1,
}
if r.deps.Updates == nil || userID == 0 || channelID == 0 || availableMinID <= 0 {
return event
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return event
}
return recorded
}
func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) {
if !read.Changed || read.ChannelID == 0 {
return domain.UpdateEvent{}, nil
@ -346,23 +349,41 @@ func (r *Router) channelFanoutRecipients(ctx context.Context, scope channelFanou
online = provider.OnlineChannelMemberUserIDs(channelID, domain.MaxChannelRealtimeFanout)
case channelFanoutViewers:
online = provider.OnlineChannelUserIDs(channelID, domain.MaxChannelRealtimeFanout)
case channelFanoutMessageBox:
online = provider.OnlineChannelMemberUserIDs(channelID, domain.MaxChannelRealtimeFanout)
if subscriptions, ok := r.deps.Sessions.(ChannelSubscriptionProvider); ok {
online = append(online, subscriptions.OnlineChannelSubscriberUserIDs(channelID, domain.MaxChannelRealtimeFanout)...)
}
}
if len(online) == 0 {
return uniqueRecipientIDs(explicit)
}
active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online)
var (
authorized []int64
err error
)
if scope == channelFanoutMembers {
authorized, err = r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online)
} else if audience, ok := r.deps.Channels.(ChannelMessageAudienceService); ok {
authorized, err = audience.FilterMessageAudienceIDs(ctx, channelID, online)
} else {
// Test/minimal adapters without public-preview authorization retain the
// former member-only behavior; production channels.Service implements
// ChannelMessageAudienceService.
authorized, err = r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online)
}
if err != nil {
return uniqueRecipientIDs(explicit)
}
if len(active) == 0 && len(explicit) == 0 {
if len(authorized) == 0 && len(explicit) == 0 {
return nil
}
if len(active) > domain.MaxChannelRealtimeFanout {
active = active[:domain.MaxChannelRealtimeFanout]
if len(authorized) > domain.MaxChannelRealtimeFanout {
authorized = authorized[:domain.MaxChannelRealtimeFanout]
}
out := uniqueRecipientIDs(active)
out := uniqueRecipientIDs(authorized)
seen := make(map[int64]struct{}, len(out)+len(explicit))
for _, userID := range active {
for _, userID := range authorized {
if userID == 0 {
continue
}
@ -403,18 +424,30 @@ func uniqueRecipientIDs(ids []int64) []int64 {
}
func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) {
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false)
// The third-party verification icon is resolved once here, outside the
// per-recipient builder: see channelStateUpdatesWithLinkedMonoforum.
icon := r.peerBotVerificationIcon(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID})
usernames := r.channelStateUsernameRegistry(ctx, channel, domain.Channel{}, false)
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false, icon, usernames)
}
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) {
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool, botVerificationIcon int64, usernames map[domain.Peer][]domain.Username) {
if r.deps.Channels == nil || channel.ID == 0 {
return
}
r.pushChannelUpdates(ctx, originUserID, channel.ID, []int64{originUserID}, func(viewerUserID int64) *tg.Updates {
return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono)
return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono, botVerificationIcon, usernames)
})
}
func (r *Router) channelStateUsernameRegistry(ctx context.Context, channel domain.Channel, mono domain.Channel, includeMono bool) map[domain.Peer][]domain.Username {
peers := []domain.Peer{{Type: domain.PeerTypeChannel, ID: channel.ID}}
if includeMono && mono.ID != 0 && mono.ID != channel.ID {
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID})
}
return r.usernameRegistryMap(ctx, peers)
}
func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass {
if r.deps.Users == nil || len(ids) == 0 {
return nil

View file

@ -93,7 +93,7 @@ func (r *Router) onMessagesUpdatePinnedMessage(ctx context.Context, req *tg.Mess
// builder 无 Users 数组(仅 pinned update + ChatMin),无需 owner 预热。pin 的真实变更由
// UpdatePinnedChannelMessages{pts} 承载、可经 getChannelDifference 兜底,bundled 的无 pts
// UpdateChannel 对 pin 冗余(pts payload 已含变更),丢弃无害——与 unpinAll 取舍一致。
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelPinnedUpdates(viewerUserID, res)
})
return updates, nil

View file

@ -45,8 +45,14 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
}
return nil, channelInvalidErr(err)
}
if diff.Channel.Username != "" && diff.Self.Status != domain.ChannelMemberActive {
// Telegram's public-channel passive delivery is enabled only after a
// successful short-poll difference. The runtime subscription is renewed
// by subsequent polls and never creates membership/dialog/read state.
r.refreshPublicChannelSubscription(ctx, userID, channelID)
}
diff = r.enrichChannelDifference(ctx, userID, diff)
out := tgChannelDifference(userID, diff)
out := r.tgChannelDifference(ctx, userID, diff)
if linked, ok := r.linkedDiscussionChat(ctx, userID, channelID); ok {
switch value := out.(type) {
case *tg.UpdatesChannelDifference:
@ -139,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,
@ -152,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
}
@ -167,12 +183,20 @@ func (r *Router) linkedMonoforumForChannelState(ctx context.Context, userID int6
}
func (r *Router) channelMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64, cache *viewerPeerCache) *tg.Updates {
updates := r.channelMessageUpdatesWithPeerCacheAndUsernames(ctx, viewerUserID, res, randomID, cache, nil)
if updates != nil {
r.applyUsernamesToPeerObjects(ctx, updates.Users, updates.Chats)
}
return updates
}
func (r *Router) channelMessageUpdatesWithPeerCacheAndUsernames(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64, cache *viewerPeerCache, usernames map[domain.Peer][]domain.Username) *tg.Updates {
randomIDs := []int64(nil)
includeMessageIDs := randomID != 0
if includeMessageIDs {
randomIDs = []int64{randomID}
}
return r.channelMessagesUpdatesWithPeerCache(ctx, viewerUserID, []domain.SendChannelMessageResult{res}, randomIDs, includeMessageIDs, nil, cache)
return r.channelMessagesUpdatesWithPeerCacheAndUsernames(ctx, viewerUserID, []domain.SendChannelMessageResult{res}, randomIDs, includeMessageIDs, nil, cache, usernames)
}
func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID int64, discussion *domain.SendChannelDiscussionResult) {
@ -193,6 +217,14 @@ func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID i
}
func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64, cache *viewerPeerCache) *tg.Updates {
updates := r.channelMessagesUpdatesWithPeerCacheAndUsernames(ctx, viewerUserID, results, randomIDs, includeMessageIDs, extraUserIDs, cache, nil)
if updates != nil {
r.applyUsernamesToPeerObjects(ctx, updates.Users, updates.Chats)
}
return updates
}
func (r *Router) channelMessagesUpdatesWithPeerCacheAndUsernames(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64, cache *viewerPeerCache, usernames map[domain.Peer][]domain.Username) *tg.Updates {
if cache == nil {
cache = newViewerPeerCache(r)
}
@ -245,13 +277,15 @@ func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewer
if date == 0 {
date = int(r.clock.Now().Unix())
}
return &tg.Updates{
out := &tg.Updates{
Updates: updates,
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
Chats: chats,
Date: date,
Seq: 0,
}
applyUsernamesFromRegistry(out.Users, out.Chats, usernames)
return out
}
func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult) *tg.Updates {
@ -259,6 +293,14 @@ func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int
}
func (r *Router) channelEditMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult, cache *viewerPeerCache) *tg.Updates {
updates := r.channelEditMessageUpdatesWithPeerCacheAndUsernames(ctx, viewerUserID, res, cache, nil)
if updates != nil {
r.applyUsernamesToPeerObjects(ctx, updates.Users, updates.Chats)
}
return updates
}
func (r *Router) channelEditMessageUpdatesWithPeerCacheAndUsernames(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult, cache *viewerPeerCache, usernames map[domain.Peer][]domain.Username) *tg.Updates {
if cache == nil {
cache = newViewerPeerCache(r)
}
@ -281,13 +323,15 @@ func (r *Router) channelEditMessageUpdatesWithPeerCache(ctx context.Context, vie
}
chats := []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)}
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), res.Channel.ID)))...)
return &tg.Updates{
out := &tg.Updates{
Updates: updates,
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
Chats: chats,
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
applyUsernamesFromRegistry(out.Users, out.Chats, usernames)
return out
}
func (r *Router) channelDeleteMessagesUpdates(viewerUserID int64, channel domain.Channel, event domain.ChannelUpdateEvent) *tg.Updates {

View file

@ -10,14 +10,35 @@ import (
"go.uber.org/zap/zaptest"
accountapp "telesrv/internal/app/account"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestQuickReplyRPCSaveListAndDeleteMessage(t *testing.T) {
const userID int64 = 1000000001
const userID int64 = domain.UserIDSequenceBase
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), [8]byte{1}), 77)
r, updates := newChatAutomationTestRouter(t)
verify := newFakeBotVerifications()
r.deps.BotVerifications = verify
userStore := memory.NewUserStore()
self, err := userStore.Create(context.Background(), domain.User{
AccessHash: 7000001,
Phone: "15550007001",
FirstName: "Quick",
})
if err != nil || self.ID != userID {
t.Fatalf("create quick-reply user = %+v err %v, want id %d", self, err, userID)
}
r.deps.Users = appusers.NewService(userStore)
const quickReplyIcon = int64(8800023)
quickReplyPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
verify.marks[quickReplyPeer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: quickReplyPeer,
IconDocumentID: quickReplyIcon,
Description: "Verified quick-reply peer",
}
got, err := r.onMessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
@ -68,6 +89,17 @@ func TestQuickReplyRPCSaveListAndDeleteMessage(t *testing.T) {
t.Fatalf("quick replies = %#v", list)
}
quickReplyMessages, err := r.onMessagesGetQuickReplyMessages(ctx, &tg.MessagesGetQuickReplyMessagesRequest{
ShortcutID: shortcutID,
})
if err != nil {
t.Fatalf("onMessagesGetQuickReplyMessages: %v", err)
}
assertMessagesEnvelopeBotVerificationIcon(t, quickReplyMessages, quickReplyPeer, quickReplyIcon)
if verify.batchCalls != 0 || verify.peerCalls != 1 {
t.Fatalf("quick-reply verification reads = batch %d peer %d, want 0/1 for one peer", verify.batchCalls, verify.peerCalls)
}
deleted, err := r.onMessagesDeleteQuickReplyMessages(ctx, &tg.MessagesDeleteQuickReplyMessagesRequest{
ShortcutID: shortcutID,
ID: []int{messageID},
@ -97,6 +129,15 @@ func TestBusinessChatLinkRPCs(t *testing.T) {
const userID int64 = 1000000002
ctx := WithUserID(context.Background(), userID)
r, _ := newChatAutomationTestRouter(t)
r.deps.Users = mapUsersService{users: map[int64]domain.User{
userID: {ID: userID, AccessHash: 2002, FirstName: "Business", Username: "business_slot"},
}}
registry := newFakeUsernameRegistry()
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: userID}] = []domain.Username{
{Username: "business_slot", Editable: true, Active: true, SortOrder: 0},
{Username: "business_collectible", Active: true, SortOrder: 1, CollectibleID: 22},
}
r.deps.Usernames = registry
created, err := r.onAccountCreateBusinessChatLink(ctx, tg.InputBusinessChatLink{
Message: "Prefilled message",
@ -124,6 +165,13 @@ func TestBusinessChatLinkRPCs(t *testing.T) {
if !ok || peer.UserID != userID || resolved.Message != "Prefilled message" {
t.Fatalf("resolved = %+v", resolved)
}
if len(resolved.Users) != 1 {
t.Fatalf("resolved users = %+v, want one", resolved.Users)
}
assertVectorOnlyUsernames(t, "resolved business chat owner", resolved.Users[0].(*tg.User), []string{"business_slot", "business_collectible"})
if registry.peerCalls != 1 || registry.batchCalls != 0 {
t.Fatalf("resolved business username reads = peer:%d batch:%d, want 1/0", registry.peerCalls, registry.batchCalls)
}
list, err = r.onAccountGetBusinessChatLinks(ctx)
if err != nil || len(list.Links) != 1 || list.Links[0].Views != 1 {
t.Fatalf("post-resolve links = %+v err=%v", list, err)

File diff suppressed because it is too large Load diff

View file

@ -478,6 +478,7 @@ func (r *Router) onContactsGetBlocked(ctx context.Context, req *tg.ContactsGetBl
})
users = append(users, r.tgUser(item.User))
}
r.applyUsernamesToPeerObjects(ctx, users, nil)
if list.Count > len(blocked)+req.Offset {
return &tg.ContactsBlockedSlice{Count: list.Count, Blocked: blocked, Chats: []tg.ChatClass{}, Users: users}, nil
}
@ -540,6 +541,7 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
}
}
}
statusVisible := r.statusTimestampVisibleToViewer(ctx, contactUserIDs, userID)
seen = make(map[int64]struct{}, len(list.Contacts))
for _, contact := range list.Contacts {
id := contact.User.ID
@ -555,9 +557,19 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
u.LastSeenAt = current.LastSeenAt
u.Status = current.Status
}
status := u.Status
if statusVisible[id] {
status = r.userPresenceStatusForUser(u)
} else {
switch status.Kind {
case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty:
default:
status = domain.ApproximateUserStatus(u.LastSeenAt, int(r.clock.Now().Unix()))
}
}
out = append(out, tg.ContactStatus{
UserID: id,
Status: tgUserStatus(r.userPresenceStatusForUser(u)),
Status: tgUserStatus(status),
})
}
return out, nil
@ -626,7 +638,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}
@ -824,6 +836,7 @@ func (r *Router) onContactsDeleteContacts(ctx context.Context, ids []tg.InputUse
}
}
r.invalidateRPCProjectionForViewer(userID)
r.applyUsernamesToPeerObjects(ctx, users, nil)
out := &tg.Updates{Updates: updates, Users: users, Date: int(r.clock.Now().Unix())}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
return out, nil
@ -863,7 +876,7 @@ func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.Contac
// private note into the shared update log.
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
} else {
r.pushUserUpdates(ctx, userID, r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), true))
r.pushUserUpdates(ctx, userID, r.contactNoteRefreshUpdates(ctx, userID, peerUser, int(r.clock.Now().Unix()), true))
}
return true, nil
}
@ -1052,17 +1065,19 @@ func contactUserForUpdates(contact domain.Contact) domain.User {
return peerUser
}
func (r *Router) contactNoteRefreshUpdates(peerUser domain.User, date int, includeContactsReset bool) *tg.Updates {
func (r *Router) contactNoteRefreshUpdates(ctx context.Context, viewerUserID int64, peerUser domain.User, date int, includeContactsReset bool) *tg.Updates {
updates := make([]tg.UpdateClass, 0, 2)
if includeContactsReset {
updates = append(updates, &tg.UpdateContactsReset{})
}
updates = append(updates, &tg.UpdateUser{UserID: peerUser.ID})
return &tg.Updates{
out := &tg.Updates{
Updates: updates,
Users: []tg.UserClass{r.tgUser(peerUser)},
Date: date,
}
r.applyUsernamesToPeerObjects(ctx, out.Users, nil)
return out
}
// pushContactNoteRefreshIfReliableDispatch complements the durable
@ -1077,7 +1092,7 @@ func (r *Router) pushContactNoteRefreshIfReliableDispatch(ctx context.Context, u
ctx,
userID,
"push contact note full-user refresh",
r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), false),
r.contactNoteRefreshUpdates(ctx, userID, peerUser, int(r.clock.Now().Unix()), false),
)
}
@ -1100,7 +1115,7 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil)
r.applyPeerReadModels(ctx, userID, out.Users, nil)
return out
}

View file

@ -13,6 +13,7 @@ import (
appprivacy "telesrv/internal/app/privacy"
appstories "telesrv/internal/app/stories"
appupdates "telesrv/internal/app/updates"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/app/userprojection"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -24,6 +25,8 @@ import (
func TestContactsSearchFindsUsers(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
registry := memory.NewCollectibleUsernameStore()
users.AttachUsernameRegistry(registry)
owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
@ -32,12 +35,29 @@ func TestContactsSearchFindsUsers(t *testing.T) {
if err != nil {
t.Fatalf("create friend: %v", err)
}
friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID}
if _, err := registry.SetEditableUsername(ctx, friendPeer, friend.Username); err != nil {
t.Fatalf("seed editable username: %v", err)
}
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
Username: "nft4",
Owner: friendPeer,
Currency: domain.CollectibleCurrencyStars,
Amount: 1,
Actor: "test",
}); err != nil || !created {
t.Fatalf("mint collectible: created=%v err=%v", created, err)
}
r := New(Config{}, Deps{
Contacts: appcontacts.NewService(memory.NewContactStore(), users),
Usernames: usernamesapp.NewService(
usernamesapp.WithRegistryStore(registry),
usernamesapp.WithCollectibleStore(registry),
),
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
if err := (&tg.ContactsSearchRequest{Q: "@search", Limit: 20}).Encode(&in); err != nil {
if err := (&tg.ContactsSearchRequest{Q: "@NFT4", Limit: 20}).Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in)
@ -55,6 +75,11 @@ func TestContactsSearchFindsUsers(t *testing.T) {
if !ok || peer.UserID != friend.ID {
t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0])
}
user := box.Users[0].(*tg.User)
vector := assertVectorOnlyUsernames(t, "contacts.search result", user, []string{"search_friend", "nft4"})
if !vector[1].Active {
t.Fatalf("search result username vector = %+v, want active nft4 alias", vector)
}
}
func TestContactsEditCloseFriendsProjectsUserFlag(t *testing.T) {
@ -672,8 +697,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"})
@ -1276,7 +1301,7 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T
withoutException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
ID: &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Phone: bob.Phone,
Phone: "",
FirstName: "Bobby",
})
if err != nil {
@ -1291,6 +1316,19 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T
} else if allowed {
t.Fatalf("bob can see alice phone = true, want false before exception")
}
withoutExceptionUpdates := withoutException.(*tg.Updates)
for _, item := range withoutExceptionUpdates.Users {
if user, ok := item.(*tg.User); ok && user.ID == bob.ID && user.Phone != "" {
t.Fatalf("contacts.addContact(phone=\"\") leaked bob phone %q in updates", user.Phone)
}
}
storedBob, found, err := contactsStore.Get(ctx, alice.ID, bob.ID)
if err != nil || !found {
t.Fatalf("stored bob contact found=%v err=%v", found, err)
}
if storedBob.Phone != "" || storedBob.User.Phone != "" {
t.Fatalf("stored bob contact phone = local %q user %q, want empty", storedBob.Phone, storedBob.User.Phone)
}
withException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
AddPhonePrivacyException: true,
@ -1538,8 +1576,8 @@ func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) {
if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID {
t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID)
}
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID {
t.Fatalf("blocked user = %#v, want alice", full.Users[0])
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID || user.Phone != "" {
t.Fatalf("blocked user = %#v, want alice with hidden phone", full.Users[0])
}
ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{

View file

@ -222,6 +222,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
switch action.Type {
case domain.ChannelActionCreate:
return &tg.MessageActionChannelCreate{Title: action.Title}
case domain.ChannelActionHistoryClear:
return &tg.MessageActionHistoryClear{}
case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined:
return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)}
case domain.ChannelActionChatJoinedByLink:
@ -495,7 +497,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)
@ -543,16 +544,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
return out
}
// channelAboutWithModerationWarning decorates the projected channel/supergroup
// About with the scam/fake warning when set (group vs channel wording).
func channelAboutWithModerationWarning(ch domain.Channel) string {
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
if ch.Megagroup && !ch.Broadcast {
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
}
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
}
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
ch := view.Channel
full := &tg.ChannelFull{
@ -563,13 +554,15 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
About: channelAboutWithModerationWarning(ch),
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
// Official clients render localized warnings from scam/fake flags.
// About remains the owner's unmodified description.
About: ch.About,
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
}
if ch.ParticipantsCount > 0 {
full.SetParticipantsCount(ch.ParticipantsCount)

View file

@ -1,81 +0,0 @@
package rpc
import (
"strings"
"sync/atomic"
)
// Scam/fake profile warnings surfaced in the full-profile About text.
//
// Telegram Desktop only ships the SCAM/FAKE badge strings and renders no
// warning paragraph, while iOS/Android show a localized warning. To make the
// warning visible on every client, the server injects it into the projected
// getFullUser/getFullChannel About field. Injection is non-destructive: the
// stored bio/description is never overwritten, only the response is decorated,
// so clearing the flag restores the original text and the warning survives the
// owner editing their bio/description (it is re-applied from the flag on every
// read).
//
// The text is server-provided (clients cannot localize it). Operators override
// it via TELESRV_SCAM_WARNING / TELESRV_FAKE_WARNING; when unset the built-in
// per-peer-type English defaults are used. scam takes precedence over fake.
const (
defaultScamWarningUser = "\u26A0\uFE0F Warning: Many users reported this account as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningUser = "\u26A0\uFE0F Warning: Many users reported that this account impersonates a famous person or organization."
defaultScamWarningChannel = "\u26A0\uFE0F Warning: Many users reported this channel as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningChannel = "\u26A0\uFE0F Warning: Many users reported that this channel impersonates a famous person or organization."
defaultScamWarningGroup = "\u26A0\uFE0F Warning: Many users reported this group as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningGroup = "\u26A0\uFE0F Warning: Many users reported that this group impersonates a famous person or organization."
)
// moderationWarningOverrides holds the operator-configured texts. They are set
// once at startup (SetModerationWarnings) before any request is served, and
// read on the hot path; atomic.Pointer keeps that race-free without locking.
var moderationWarningOverrides atomic.Pointer[moderationWarningConfig]
type moderationWarningConfig struct {
scam string
fake string
}
// SetModerationWarnings installs operator overrides for the scam/fake profile
// warnings. Empty strings keep the built-in per-peer-type defaults. A single
// override applies to every peer type (user/channel/group).
func SetModerationWarnings(scam, fake string) {
moderationWarningOverrides.Store(&moderationWarningConfig{
scam: strings.TrimSpace(scam),
fake: strings.TrimSpace(fake),
})
}
func moderationOverride() moderationWarningConfig {
if cfg := moderationWarningOverrides.Load(); cfg != nil {
return *cfg
}
return moderationWarningConfig{}
}
// aboutWithModerationWarning prepends the scam/fake warning to a profile About.
// It returns about unchanged when neither flag is set. The operator override
// wins over the per-type default; scam wins over fake when both are set.
func aboutWithModerationWarning(about, scamDefault, fakeDefault string, scam, fake bool) string {
override := moderationOverride()
warning := ""
switch {
case scam:
if warning = override.scam; warning == "" {
warning = scamDefault
}
case fake:
if warning = override.fake; warning == "" {
warning = fakeDefault
}
}
if warning == "" {
return about
}
if about = strings.TrimSpace(about); about == "" {
return warning
}
return warning + "\n\n" + about
}

View file

@ -0,0 +1,30 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func TestTGMessageProjectsHistoryClearServiceAction(t *testing.T) {
message := domain.NewHistoryClearMessage(
1001,
domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
77,
88,
1700000000,
9,
)
got, ok := tgMessage(message).(*tg.MessageService)
if !ok {
t.Fatalf("message = %T, want *tg.MessageService", tgMessage(message))
}
if got.ID != 77 || !got.Out || got.PeerID == nil || got.FromID == nil {
t.Fatalf("service message = %+v, want owner-local id/peer/from", got)
}
if _, ok := got.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("action = %T, want *tg.MessageActionHistoryClear", got.Action)
}
}

View file

@ -135,6 +135,25 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
return &tg.MessageMediaEmpty{}
}
return tgWebPageMedia(*m.WebPage)
case domain.MessageMediaKindGiveaway:
if m.Giveaway == nil {
return &tg.MessageMediaEmpty{}
}
out := &tg.MessageMediaGiveaway{
OnlyNewSubscribers: m.Giveaway.OnlyNewSubscribers,
WinnersAreVisible: m.Giveaway.WinnersAreVisible,
Channels: append([]int64(nil), m.Giveaway.Channels...),
Quantity: m.Giveaway.Quantity,
UntilDate: m.Giveaway.UntilDate,
}
if m.Giveaway.CountriesISO2 != nil {
out.SetCountriesISO2(append([]string(nil), m.Giveaway.CountriesISO2...))
}
if m.Giveaway.PrizeDescription != "" {
out.SetPrizeDescription(m.Giveaway.PrizeDescription)
}
out.SetStars(m.Giveaway.Stars)
return out
default:
return &tg.MessageMediaEmpty{}
}

View file

@ -1,7 +1,10 @@
package rpc
import (
"time"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
@ -134,6 +137,8 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
return nil
}
switch m.ServiceAction.Kind {
case domain.MessageServiceActionHistoryClear:
return &tg.MessageActionHistoryClear{}
case domain.MessageServiceActionSuggestProfilePhoto:
if m.ServiceAction.Photo == nil || m.ServiceAction.Photo.ID == 0 {
return &tg.MessageActionEmpty{}
@ -145,6 +150,25 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
return &tg.MessageActionSetChatTheme{
Theme: &tg.ChatTheme{Emoticon: m.ServiceAction.ChatThemeEmoticon},
}
case domain.MessageServiceActionNoForwardsToggle:
action := m.ServiceAction.NoForwards
if action == nil {
return &tg.MessageActionEmpty{}
}
return &tg.MessageActionNoForwardsToggle{
PrevValue: action.PrevValue,
NewValue: action.NewValue,
}
case domain.MessageServiceActionNoForwardsRequest:
action := m.ServiceAction.NoForwards
if action == nil {
return &tg.MessageActionEmpty{}
}
return &tg.MessageActionNoForwardsRequest{
Expired: action.Expired || (action.ExpiresAt > 0 && int(time.Now().Unix()) >= action.ExpiresAt),
PrevValue: action.PrevValue,
NewValue: action.NewValue,
}
case domain.MessageServiceActionPhoneCall:
if m.ServiceAction.Call == nil {
return &tg.MessageActionEmpty{}
@ -219,7 +243,22 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
Peers: tgPeerList(shared.Peers),
}
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
case domain.MessageServiceActionGiftStars:
action := m.ServiceAction.GiftStars
if action == nil || action.Currency == "" || action.Amount <= 0 || action.Stars <= 0 {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionGiftStars{
Currency: action.Currency,
Amount: action.Amount,
Stars: action.Stars,
}
// Telegram only exposes the provider transaction id to the receiver.
if !msg.Out && action.TransactionID != "" {
out.SetTransactionID(action.TransactionID)
}
return out
case domain.MessageServiceActionStarGiftUnique:
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
case domain.MessageServiceActionStarGiftOffer:
@ -444,6 +483,9 @@ func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions)
if in.CanSeeList {
out.SetCanSeeList(true)
}
if in.AsTags {
out.SetReactionsAsTags(true)
}
for _, item := range in.Results {
reaction := tgMessageReaction(item.Reaction)
if reaction == nil || item.Count <= 0 {

View file

@ -50,6 +50,192 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
return out, nil
}
// richMessageMediaRefs is the media closure referenced by one PageBlock graph.
// IDs retain first-reference order so every projection is deterministic.
type richMessageMediaRefs struct {
photoIDs []int64
documentIDs []int64
photos map[int64]struct{}
documents map[int64]struct{}
}
func collectRichMessageMediaRefs(blocks []tg.PageBlockClass) (richMessageMediaRefs, error) {
refs := richMessageMediaRefs{
photos: make(map[int64]struct{}),
documents: make(map[int64]struct{}),
}
if err := refs.collectBlocks(blocks); err != nil {
return richMessageMediaRefs{}, err
}
return refs, nil
}
func (r *richMessageMediaRefs) addPhoto(id int64, required bool) error {
if id == 0 {
if required {
return photoInvalidErr()
}
return nil
}
if _, ok := r.photos[id]; ok {
return nil
}
r.photos[id] = struct{}{}
r.photoIDs = append(r.photoIDs, id)
return nil
}
func (r *richMessageMediaRefs) addDocument(id int64) error {
if id == 0 {
return mediaInvalidErr()
}
if _, ok := r.documents[id]; ok {
return nil
}
r.documents[id] = struct{}{}
r.documentIDs = append(r.documentIDs, id)
return nil
}
func (r *richMessageMediaRefs) collectBlocks(blocks []tg.PageBlockClass) error {
for _, block := range blocks {
switch value := block.(type) {
case *tg.PageBlockPhoto:
if err := r.addPhoto(value.PhotoID, true); err != nil {
return err
}
case *tg.PageBlockVideo:
if err := r.addDocument(value.VideoID); err != nil {
return err
}
case *tg.PageBlockAudio:
if err := r.addDocument(value.AudioID); err != nil {
return err
}
case *tg.PageBlockEmbed:
if id, ok := value.GetPosterPhotoID(); ok {
if err := r.addPhoto(id, false); err != nil {
return err
}
}
case *tg.PageBlockEmbedPost:
if err := r.addPhoto(value.AuthorPhotoID, false); err != nil {
return err
}
if err := r.collectBlocks(value.Blocks); err != nil {
return err
}
case *tg.PageBlockRelatedArticles:
for i := range value.Articles {
if id, ok := value.Articles[i].GetPhotoID(); ok {
if err := r.addPhoto(id, false); err != nil {
return err
}
}
}
case *tg.PageBlockList:
for _, item := range value.Items {
if item, ok := item.(*tg.PageListItemBlocks); ok {
if err := r.collectBlocks(item.Blocks); err != nil {
return err
}
}
}
case *tg.PageBlockOrderedList:
for _, item := range value.Items {
if item, ok := item.(*tg.PageListOrderedItemBlocks); ok {
if err := r.collectBlocks(item.Blocks); err != nil {
return err
}
}
}
case *tg.PageBlockCover:
if err := r.collectBlocks([]tg.PageBlockClass{value.Cover}); err != nil {
return err
}
case *tg.PageBlockCollage:
if err := r.collectBlocks(value.Items); err != nil {
return err
}
case *tg.PageBlockSlideshow:
if err := r.collectBlocks(value.Items); err != nil {
return err
}
case *tg.PageBlockDetails:
if err := r.collectBlocks(value.Blocks); err != nil {
return err
}
case *tg.PageBlockBlockquoteBlocks:
if err := r.collectBlocks(value.Blocks); err != nil {
return err
}
}
}
return nil
}
type richMessagePhotoBatchProvider interface {
GetPhotos(context.Context, []int64) ([]domain.Photo, error)
}
func (r *Router) resolveRichMessagePhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
if len(ids) == 0 {
return nil, nil
}
resolved := make(map[int64]domain.Photo, len(ids))
if batch, ok := r.deps.Files.(richMessagePhotoBatchProvider); ok {
photos, err := batch.GetPhotos(ctx, ids)
if err != nil {
return nil, internalErr()
}
for _, photo := range photos {
resolved[photo.ID] = photo
}
} else {
for _, id := range ids {
photo, found, err := r.deps.Files.GetPhoto(ctx, id)
if err != nil {
return nil, internalErr()
}
if found {
resolved[id] = photo
}
}
}
out := make([]domain.Photo, 0, len(ids))
for _, id := range ids {
photo, ok := resolved[id]
if !ok || photo.ID != id || len(photo.Sizes) == 0 {
return nil, photoInvalidErr()
}
out = append(out, photo)
}
return out, nil
}
func (r *Router) resolveRichMessageDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) {
if len(ids) == 0 {
return nil, nil
}
documents, err := r.deps.Files.GetDocuments(ctx, ids)
if err != nil {
return nil, internalErr()
}
resolved := make(map[int64]domain.Document, len(documents))
for _, document := range documents {
resolved[document.ID] = document
}
out := make([]domain.Document, 0, len(ids))
for _, id := range ids {
document, ok := resolved[id]
if !ok || document.ID != id {
return nil, mediaInvalidErr()
}
out = append(out, document)
}
return out, nil
}
func normalizeRichBlocksForClients(blocks []tg.PageBlockClass) {
for _, block := range blocks {
normalizeRichBlockForClients(block)
@ -172,7 +358,21 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
if err := validateRichMessageBlocks(in.Blocks); err != nil {
return nil, err
}
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
for _, photo := range in.Photos {
if _, ok := inputPhotoID(photo); !ok {
return nil, photoInvalidErr()
}
}
for _, document := range in.Documents {
if _, ok := inputDocumentID(document); !ok {
return nil, mediaInvalidErr()
}
}
refs, err := collectRichMessageMediaRefs(in.Blocks)
if err != nil {
return nil, err
}
if (len(refs.photoIDs) > 0 || len(refs.documentIDs) > 0) && r.deps.Files == nil {
return nil, notImplementedErr()
}
normalizeRichBlocksForClients(in.Blocks)
@ -191,33 +391,13 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
if projectionErr == nil {
rich.BotAPIProjection = projection
}
for _, p := range in.Photos {
id, ok := inputPhotoID(p)
if !ok {
return nil, photoInvalidErr()
}
photo, found, err := r.deps.Files.GetPhoto(ctx, id)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
rich.Photos = append(rich.Photos, photo)
rich.Photos, err = r.resolveRichMessagePhotos(ctx, refs.photoIDs)
if err != nil {
return nil, err
}
for _, d := range in.Documents {
id, ok := inputDocumentID(d)
if !ok {
return nil, mediaInvalidErr()
}
doc, found, err := r.deps.Files.GetDocument(ctx, id)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, mediaInvalidErr()
}
rich.Documents = append(rich.Documents, doc)
rich.Documents, err = r.resolveRichMessageDocuments(ctx, refs.documentIDs)
if err != nil {
return nil, err
}
if rich.IsZero() {
return nil, nil

View file

@ -1,28 +1,55 @@
package rpc
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"hash/fnv"
"sort"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// themesListHash 计算一组主题的稳定哈希(服务端权威,客户端原样回传)。对 id 升序折叠,
// 与返回顺序无关;主题集合变化(用户新建/安装/卸载)即变,驱动客户端重取。
func themesListHash(themes []tg.Theme) int64 {
ids := make([]int64, 0, len(themes))
for _, t := range themes {
ids = append(ids, t.ID)
// themesListHash 计算一组主题完整 wire 内容的稳定哈希。编码后排序使返回顺序不影响
// 哈希;标题、document、settings 或颜色等任一可见内容变化都会驱动客户端重取。
func themesListHash(themes []tg.Theme) (int64, error) {
encoded := make([][]byte, 0, len(themes))
for i := range themes {
theme := themes[i]
if settings, ok := theme.GetSettings(); ok {
copied := append([]tg.ThemeSettings(nil), settings...)
for j := range copied {
if colors, ok := copied[j].GetMessageColors(); ok {
copied[j].SetMessageColors(append([]int(nil), colors...))
}
}
theme.SetSettings(copied)
}
var b bin.Buffer
if err := theme.Encode(&b); err != nil {
return 0, fmt.Errorf("encode theme %d for hash: %w", theme.ID, err)
}
encoded = append(encoded, b.Copy())
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var h uint64 = 0xcbf29ce484222325 // FNV-1a 64 offset basis
for _, id := range ids {
h ^= uint64(id)
h *= 0x100000001b3
sort.Slice(encoded, func(i, j int) bool {
return bytes.Compare(encoded[i], encoded[j]) < 0
})
h := fnv.New64a()
var size [8]byte
for _, body := range encoded {
binary.LittleEndian.PutUint64(size[:], uint64(len(body)))
_, _ = h.Write(size[:])
_, _ = h.Write(body)
}
return int64(h & 0x7fffffffffffffff)
value := int64(h.Sum64() & 0x7fffffffffffffff)
if value == 0 {
value = 1
}
return value, nil
}
// themeRefFromInput 把 tg.InputThemeClass(inputTheme / inputThemeSlug)转成 domain.ThemeRef。

View file

@ -27,6 +27,9 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
}
if balance := tgGiftStarsBalanceUpdate(event.Message); balance != nil {
out.OtherUpdates = append(out.OtherUpdates, balance)
}
case domain.UpdateEventReadHistoryInbox:
if update := tgReadHistoryInboxUpdate(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
@ -35,7 +38,7 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
if update := tgReadHistoryOutboxUpdate(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
}
case domain.UpdateEventMessageReactions, domain.UpdateEventMessagePoll:
case domain.UpdateEventMessagePoll:
// 同时下发消息快照(含最新聚合)与对应通知 update;事件无 TL pts,
// pts 推进靠 difference state 本身。
if msg := tgMessage(event.Message); msg != nil {
@ -55,11 +58,23 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
if nudge.ChannelID == 0 {
continue
}
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 {
update.SetPts(nudge.Pts)
if nudge.AvailableMinID > 0 {
out.OtherUpdates = append(out.OtherUpdates, &tg.UpdateChannelAvailableMessages{
ChannelID: nudge.ChannelID,
AvailableMinID: nudge.AvailableMinID,
})
}
// Preserve compatibility for older domain callers that only supplied
// Pts: a nudge without an owner-local boundary is a shared channel
// update nudge. New store results set ChannelUpdatesDirty explicitly so
// a channel can carry both absolute clear and too-long updates.
if nudge.ChannelUpdatesDirty || nudge.AvailableMinID == 0 {
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 {
update.SetPts(nudge.Pts)
}
out.OtherUpdates = append(out.OtherUpdates, update)
}
out.OtherUpdates = append(out.OtherUpdates, update)
if nudge.Channel != nil && nudge.Channel.Channel.ID != 0 {
addChannelNudgeChat(out, seenChats, tgChannelChatForView(viewerUserID, *nudge.Channel))
}
@ -464,32 +479,6 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil
}
return tgUpdateMessagePoll(pollPeer, event.Message.ID, media.Poll)
case domain.UpdateEventMessageReactions:
if event.Message.ID <= 0 || event.Message.ID > domain.MaxMessageBoxID {
return nil
}
peer := event.Message.Peer
if peer.Type == "" || peer.ID == 0 {
peer = event.Peer
}
outPeer := tgPeer(peer)
if outPeer == nil {
return nil
}
reactions := event.Message.Reactions
if reactions == nil {
empty := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}}
reactions = &empty
}
converted := tgMessageReactions(event.UserID, reactions)
if converted == nil {
converted = &tg.MessageReactions{Results: []tg.ReactionCount{}}
}
return &tg.UpdateMessageReactions{
Peer: outPeer,
MsgID: event.Message.ID,
Reactions: *converted,
}
case domain.UpdateEventDialogFilter:
update := &tg.UpdateDialogFilter{ID: event.FilterID}
if event.DialogFilter != nil {
@ -509,14 +498,6 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventChannelAvailable:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 || event.MaxID <= 0 {
return nil
}
return &tg.UpdateChannelAvailableMessages{
ChannelID: event.Peer.ID,
AvailableMinID: event.MaxID,
}
default:
return nil
}

View file

@ -21,11 +21,12 @@ func tgSelfUser(u domain.User) *tg.User {
Phone: u.Phone,
Self: true,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
@ -58,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)
@ -238,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
@ -245,6 +248,45 @@ func tgUsernames(username string) []tg.Username {
return []tg.Username{{Editable: true, Active: true, Username: username}}
}
// tgUsernamesFromRegistry is the single builder of the full username#b4073647
// vector in stored order (see domain.SortUsernames), with editable/active taken
// from the registry row rather than assumed.
//
// It degrades to tgUsernames(fallback) whenever the registry contributed nothing
// usable, which is what keeps a missing/failing registry service byte-identical
// to the pre-collectible wire shape.
func tgUsernamesFromRegistry(list []domain.Username, fallback string) []tg.Username {
if len(list) == 0 {
return tgUsernames(fallback)
}
sorted := domain.SortUsernames(list)
out := make([]tg.Username, 0, len(sorted))
for _, item := range sorted {
name := domain.NormalizeUsername(item.Username)
if name == "" {
continue
}
out = append(out, tg.Username{
Editable: item.Editable,
Active: item.Active,
Username: name,
})
}
if len(out) == 0 {
return tgUsernames(fallback)
}
return out
}
func hasCollectibleUsername(list []domain.Username) bool {
for _, item := range list {
if item.Collectible() {
return true
}
}
return false
}
func tgContacts(list domain.ContactList) tg.ContactsContactsClass {
out := &tg.ContactsContacts{
Contacts: make([]tg.Contact, 0, len(list.Contacts)),

View file

@ -47,6 +47,14 @@ type AuthService interface {
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
}
type AuthDeliveryReportService interface {
ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error)
}
type ClientTelemetryService interface {
Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error)
}
// SessionBinder 抽象登录后 session 与 user 的在线绑定。
//
// MTProto session 的完整身份是 raw auth_key_id + session_id。所有定位单个 session
@ -218,6 +226,16 @@ type OnlineUserProvider interface {
OnlineChannelMemberUserIDs(channelID int64, limit int) []int64
}
// ChannelSubscriptionProvider is the bounded process-local implementation of
// Telegram's public-channel short-poll subscription. A successful
// updates.getChannelDifference refresh from one session enables passive channel
// updates for the whole user account until the subscription expires.
type ChannelSubscriptionProvider interface {
RefreshChannelSubscription(rawAuthKeyID [8]byte, sessionID, userID, channelID int64, ttl time.Duration)
OnlineChannelSubscriberUserIDs(channelID int64, limit int) []int64
OnlineChannelSubscriberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64
}
// ChannelNudgeProvider 暴露「频道在线成员中排除已投递集合后的剩余 user id」,用于 >cap
// 在线成员的 UpdateChannelTooLong nudge(P0-8)。SessionManager 实现;测试/未装配 fake 可不实现
// (type-assert 失败时跳过 nudge,不影响完整 payload 投递)。
@ -321,6 +339,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)
@ -474,7 +513,6 @@ type UpdatesService interface {
RecordDialogFilterOrder(ctx context.Context, stateAuthKeyID [8]byte, userID int64, order []int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFiltersReload(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelViewForumAsMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelDiscussionInbox(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
@ -563,6 +601,8 @@ type MessagesService interface {
GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error)
SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
SavedReactionTags(ctx context.Context, userID int64, savedPeer domain.Peer, limit int) ([]domain.SavedReactionTag, error)
UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error
VoteMessagePoll(ctx context.Context, userID int64, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
CloseMessagePoll(ctx context.Context, userID int64, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
ListUnreadReactionMessages(ctx context.Context, userID int64, peer domain.Peer, limit int) ([]domain.Message, error)
@ -580,6 +620,13 @@ type MessagesService interface {
DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error)
}
// PrivateNoForwardsService is an optional messages capability used by the
// private-user branch of messages.toggleNoForwards and userFull projection.
type PrivateNoForwardsService interface {
GetPrivateNoForwards(ctx context.Context, userID, peerUserID int64) (domain.PrivateNoForwardsState, error)
TogglePrivateNoForwards(ctx context.Context, userID int64, req domain.TogglePrivateNoForwardsRequest) (domain.TogglePrivateNoForwardsResult, error)
}
// TranslationService owns read-only translation and the durable per-account
// peer preference. It only exposes domain values to the RPC edge.
type TranslationService interface {
@ -686,11 +733,10 @@ type ChannelsService interface {
VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentReactions(ctx context.Context, userID int64) error
SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error)
UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error
GetPremiumBoostStatus(ctx context.Context, userID, channelID int64, now int) (domain.PremiumBoostStatus, error)
ListPremiumBoosts(ctx context.Context, userID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error)
GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error)
@ -769,6 +815,19 @@ type ChannelsService interface {
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
// ChannelMessageAudienceService is the optional production authorization
// boundary for public short-poll subscribers. Lightweight test/domain adapters
// that only model joined members may omit it and retain member-only behavior.
type ChannelMessageAudienceService interface {
FilterMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
// ChannelAuthoritativeProjectionService bypasses long-lived channel read
// models for a durable channel_state refresh emitted by an admin mutation.
type ChannelAuthoritativeProjectionService interface {
GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error)
}
// CommunitiesService abstracts the Layer 228 Community aggregation domain.
// Community containers never expose tg types and never own message/read/pts state.
type CommunitiesService interface {
@ -892,9 +951,106 @@ type EphemeralService interface {
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
}
// ModerationService accepts only final report choices. Implementations must
// validate and snapshot referenced evidence, then durably commit the immutable
// submission before returning success.
type ModerationService interface {
ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error)
ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error)
ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error)
ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error)
ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error)
ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error)
ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error)
ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error)
ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error)
}
// PremiumPromoService exposes the immutable promo media catalog through a
// domain-only boundary. File bytes remain served by upload.getFile through the
// ordinary Files service.
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)
}
// AccountRatingService exposes the stored gramsrv composite rating used by the
// userFull rating projection.
//
// It is deliberately read-only at the RPC boundary: ratings are computed by the
// bounded background worker, while profile reads only fetch the latest stored
// projection. A nil service or a read failure leaves every rating flag unset.
type AccountRatingService interface {
Rating(ctx context.Context, userID int64) (domain.AccountRating, 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
Auth AuthService
AuthDeliveryReports AuthDeliveryReportService
ClientTelemetry ClientTelemetryService
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
// explicit invokeWithLayer evidence. Production must wire the same auth-key
// store used by the MTProto edge; nil is reserved for isolated router tests.
@ -906,8 +1062,11 @@ type Deps struct {
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
AccountRatings AccountRatingService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
@ -922,7 +1081,9 @@ type Deps struct {
Channels ChannelsService
Communities CommunitiesService
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService
@ -974,6 +1135,7 @@ type GiftsService interface {
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
@ -1014,7 +1176,7 @@ type GiftsService interface {
SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error)
TonBalance(ctx context.Context, userID int64) (int64, error)
TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error)
TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
@ -1027,7 +1189,7 @@ type StarsService interface {
GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error)
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error)
ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
}
// SecretChatService 抽象私聊端对端加密(Secret Chat)握手状态机(app/secretchat)。

View file

@ -93,12 +93,24 @@ func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int
return []int64{}, nil
}
// onMessagesReportEncryptedSpam 纯记录:服务端不自动 discard、不拉黑、不产生 update
// (discard/block 由客户端独立 RPC 完成)。P1 接受并回 true。
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, _ tg.InputEncryptedChat) (bool, error) {
if _, err := r.secretChatRequireUser(ctx); err != nil {
// onMessagesReportEncryptedSpam persists an immutable chat-metadata snapshot;
// the server remains unable to inspect encrypted message plaintext. Reporting
// does not discard or block the chat and emits no update.
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, peer tg.InputEncryptedChat) (bool, error) {
if r.deps.SecretChats == nil || r.deps.Moderation == nil {
return false, notImplementedErr()
}
userID, err := r.secretChatRequireUser(ctx)
if err != nil {
return false, err
}
chat, _, _, err := r.resolveSecretChatPeer(ctx, userID, peer)
if err != nil {
return false, err
}
if _, _, err := r.deps.Moderation.ReportEncryptedSpam(ctx, userID, chat, r.clock.Now()); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -6,8 +6,10 @@ import (
"github.com/iamxvbaba/td/tg"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
// acceptChat 跑完 request→accept,返回 normal 态密聊 id 与 participant 视角 access_hash。
@ -46,6 +48,33 @@ func encNewMessagePayload(t *testing.T, rec phonePushRecord) *tg.UpdateNewEncryp
return upd
}
func TestReportEncryptedSpamPersistsMetadataOnly(t *testing.T) {
f := newEncryptedFixture(t)
chatID, participantAccessHash := f.acceptChat(t)
reports := memory.NewModerationReportStore()
f.router.deps.Moderation = appmoderation.NewService(reports)
ok, err := f.router.onMessagesReportEncryptedSpam(
f.participantCtx(),
tg.InputEncryptedChat{ChatID: chatID, AccessHash: participantAccessHash},
)
if err != nil || !ok {
t.Fatalf("report encrypted spam ok=%v err=%v", ok, err)
}
stored := reports.Reports()
if len(stored) != 1 ||
stored[0].Source != domain.ModerationSourceEncryptedSpam ||
stored[0].ReporterUserID != f.participant.ID ||
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: f.admin.ID}) ||
len(stored[0].Items) != 1 ||
stored[0].Items[0].Kind != domain.ModerationItemEncryptedChat {
t.Fatalf("stored encrypted report=%+v", stored)
}
if string(stored[0].Items[0].Evidence) == "" ||
string(stored[0].Items[0].Evidence) == "plaintext" {
t.Fatalf("encrypted metadata evidence=%s", stored[0].Items[0].Evidence)
}
}
func TestSendEncryptedRPCFlow(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)

View file

@ -245,13 +245,18 @@ func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.Ephem
if _, final := result.(*tg.ReportResultReported); !final {
return result, nil
}
if r.deps.EphemeralReports == nil {
reason, ok := moderationReasonForReportOption(string(request.Option))
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
if _, _, err := r.deps.Moderation.ReportEphemeral(
ctx, userID, target, reason, string(request.Option), request.Message, r.clock.Now(),
); err != nil {
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
return nil, internalErr()
return nil, moderationReportError(err)
}
return result, nil
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"strings"
"testing"
"time"
@ -9,6 +10,7 @@ import (
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -49,13 +51,14 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
reports := memory.NewEphemeralReportStore()
reports := memory.NewModerationReportStore()
moderation := appmoderation.NewService(reports)
ephemeral := &ephemeralReportService{target: target}
channels := &ephemeralReportChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
}}
router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System)
router := New(Config{}, Deps{Ephemeral: ephemeral, Moderation: moderation, Channels: channels}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
request := &tg.EphemeralReportMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
@ -87,7 +90,9 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
}
}
stored := reports.Reports()
if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" {
if len(stored) != 1 || stored[0].Source != domain.ModerationSourceEphemeral ||
stored[0].Comment != "evidence comment" || len(stored[0].Items) != 1 ||
!strings.Contains(string(stored[0].Items[0].Evidence), `"Message":"abuse"`) {
t.Fatalf("reports=%+v", stored)
}
if ephemeral.calls != 4 {

View file

@ -29,6 +29,9 @@ func inputConstructorInvalidErr() error { return tgerr.New(400, "INPUT_CONSTRUCT
// langCodeNotSupportedErr 表示请求的语言码没有已导入的语言包。
func langCodeNotSupportedErr() error { return tgerr.New(400, "LANG_CODE_NOT_SUPPORTED") }
// langPackInvalidErr 表示请求的语言包目录不存在。
func langPackInvalidErr() error { return tgerr.New(400, "LANG_PACK_INVALID") }
// folderIDInvalidErr 表示客户端传入多个 folder peer 或非法 folder。
func folderIDInvalidErr() error { return tgerr.New(400, "FOLDER_ID_INVALID") }
@ -149,6 +152,8 @@ func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
func subscriptionIDInvalidErr() error { return tgerr.New(400, "SUBSCRIPTION_ID_INVALID") }
func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOUNT_MISMATCH") }
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
@ -294,6 +299,8 @@ func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_
func chatForwardsRestrictedErr() error { return tgerr.New(400, "CHAT_FORWARDS_RESTRICTED") }
func requestMsgExpiredErr() error { return tgerr.New(400, "REQUEST_MSG_EXPIRED") }
func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVALID") }
func inputRequestTooLongErr() error { return tgerr.New(400, "INPUT_REQUEST_TOO_LONG") }
@ -382,6 +389,9 @@ func callProtocolFlagsInvalidErr() error {
func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") }
func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") }
func chatSendVoicesForbiddenErr() error {
return tgerr.New(403, "CHAT_SEND_VOICES_FORBIDDEN")
}
// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。
func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
@ -469,6 +479,12 @@ func passwordErr(err error) error {
return emailNotAllowedErr()
case errors.Is(err, domain.ErrEmailCodeInvalid):
return emailCodeInvalidErr()
case errors.Is(err, domain.ErrRecoveryCodeEmpty):
return tgerr.New(400, "CODE_EMPTY")
case errors.Is(err, domain.ErrRecoveryCodeInvalid):
return tgerr.New(400, "CODE_INVALID")
case errors.Is(err, domain.ErrPasswordRecoveryExpired):
return tgerr.New(400, "PASSWORD_RECOVERY_EXPIRED")
case errors.Is(err, domain.ErrPasswordRecoveryNA):
return passwordRecoveryNAErr()
default:

View file

@ -15,6 +15,22 @@ func TestPasswordErrMapsOccupiedLoginEmailToNotAllowed(t *testing.T) {
}
}
func TestPasswordErrMapsRecoveryState(t *testing.T) {
tests := []struct {
err error
want string
}{
{domain.ErrRecoveryCodeEmpty, "CODE_EMPTY"},
{domain.ErrRecoveryCodeInvalid, "CODE_INVALID"},
{domain.ErrPasswordRecoveryExpired, "PASSWORD_RECOVERY_EXPIRED"},
}
for _, tc := range tests {
if err := passwordErr(tc.err); !tgerr.Is(err, tc.want) {
t.Fatalf("passwordErr(%v)=%v, want %s", tc.err, err, tc.want)
}
}
}
func TestBindTempAuthKeyErrPreservesRecoverableRotationErrors(t *testing.T) {
tests := []struct {
err error

View file

@ -97,7 +97,7 @@ func (d *ExpiryDispatcher) dispatchChannels(ctx context.Context, now int) bool {
d.log.Warn("delete expired channel messages", zap.Int64("channel_id", req.ChannelID), zap.Ints("ids", req.IDs), zap.Error(err))
continue
}
d.router.enqueueChannelFanout(ctx, channelFanoutMembers, req.UserID, req.ChannelID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
d.router.enqueueChannelFanout(ctx, channelFanoutMessageBox, req.UserID, req.ChannelID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return d.router.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
})
}

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

@ -0,0 +1,398 @@
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))
peers = appendUsernameProjectionPeers(peers, seen, users, chats)
if len(peers) == 0 {
return
}
byPeer := r.usernameRegistryMap(ctx, peers)
if len(byPeer) == 0 {
return
}
applyUsernamesFromRegistry(users, chats, byPeer)
}
// applyUsernamesToUpdatesBatch projects one username-registry snapshot over a
// whole outbox claim. A claim may contain repeated peer objects for several
// events and viewers; collecting the peer union first keeps the hot path at one
// registry round trip rather than one read per event or online session.
func (r *Router) applyUsernamesToUpdatesBatch(ctx context.Context, updates []*tg.Updates) {
if r.deps.Usernames == nil || len(updates) == 0 {
return
}
peerCapacity := 0
for _, update := range updates {
if update != nil {
peerCapacity += len(update.Users) + len(update.Chats)
}
}
if peerCapacity == 0 {
return
}
peers := make([]domain.Peer, 0, peerCapacity)
seen := make(map[domain.Peer]struct{}, peerCapacity)
for _, update := range updates {
if update == nil {
continue
}
peers = appendUsernameProjectionPeers(peers, seen, update.Users, update.Chats)
}
if len(peers) == 0 {
return
}
byPeer := r.usernameRegistryMap(ctx, peers)
if len(byPeer) == 0 {
return
}
for _, update := range updates {
if update != nil {
applyUsernamesFromRegistry(update.Users, update.Chats, byPeer)
}
}
}
func appendUsernameProjectionPeers(peers []domain.Peer, seen map[domain.Peer]struct{}, users []tg.UserClass, chats []tg.ChatClass) []domain.Peer {
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})
}
}
return peers
}
// 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 {
// Official clients treat the legacy scalar and the complete vector as
// alternative representations. TDLib rejects a User carrying both and
// discards the complete username set, while TDesktop and DrKLO derive
// the primary username from the first active vector entry.
u.Flags.Unset(3)
u.Username = ""
u.SetUsernames(vector)
}
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
// ch.Username is the flagged scalar; GetUsername reports the empty string
// when unset, which is exactly the fallback tgUsernamesFromRegistry wants.
scalar, _ := ch.GetUsername()
if vector := tgUsernamesFromRegistry(list, scalar); len(vector) > 0 {
ch.Flags.Unset(6)
ch.Username = ""
ch.SetUsernames(vector)
}
}
}
// usernameRegistryMap loads the registry for the given peers. A single peer goes
// through PeerUsernames so a one-object projection does not pay for a batch
// round trip; anything larger goes through UsernamesBatch (no N+1). Any error
// yields an empty map, which the caller treats as "keep the legacy scalar".
func (r *Router) usernameRegistryMap(ctx context.Context, peers []domain.Peer) map[domain.Peer][]domain.Username {
if r.deps.Usernames == nil || len(peers) == 0 {
return nil
}
if len(peers) == 1 {
list, err := r.deps.Usernames.PeerUsernames(ctx, peers[0])
if err != nil || len(list) == 0 {
return nil
}
return map[domain.Peer][]domain.Username{peers[0]: list}
}
byPeer, err := r.deps.Usernames.UsernamesBatch(ctx, peers)
if err != nil {
return nil
}
return byPeer
}

View file

@ -19,7 +19,11 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
return r.onHelpGetConfig(ctx)
})
registerRPC[*tg.HelpGetNearestDCRequest](d, tlprofile.SemanticMethodHelpGetNearestDC, func(ctx context.Context, layerRequest *tg.HelpGetNearestDCRequest) (any, error) {
return tdesktop.NearestDC(r.cfg.DC), nil
return &tg.NearestDC{
Country: r.cfg.DefaultCountryCode,
ThisDC: r.cfg.DC,
NearestDC: r.cfg.DC,
}, nil
})
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) {
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil
@ -176,11 +180,10 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
return androidcompat.DismissSuggestion(req.Suggestion), nil
}
// onHelpGetPremiumPromo 返回最小真实的 Premium 状态页数据:状态文案按 viewer
// 的会员有效期生成;videos/period_options 留空——购买入口已被 appConfig
// premium_purchase_blocked=true 关闭,订阅价格 UI 不会消费这些字段(TDesktop
// 空 period_options 仅隐藏价格按钮,DrKLO 回退到无价文案,均不报错)。
// 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。
// onHelpGetPremiumPromo returns the viewer-specific Premium status plus the
// immutable, startup-seeded video catalog. Period options intentionally remain
// empty: telesrv has no subscription purchase backend and must not advertise
// dead payment URLs. All six TL fields are mandatory.
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
promo := &tg.HelpPremiumPromo{
StatusText: branding.PremiumName + " is not active on this account.",
@ -190,17 +193,42 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
PeriodOptions: []tg.PremiumSubscriptionOption{},
Users: []tg.UserClass{},
}
userID, _, err := r.currentUserID(ctx)
if err != nil || r.deps.Users == nil {
return promo, nil
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Users == nil {
return nil, notImplementedErr()
}
u, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return promo, nil
return nil, internalErr()
}
if u.ID != userID {
return nil, internalErr()
}
if u.Bot {
return nil, botMethodInvalidErr()
}
if u.PremiumActiveAt(r.clock.Now().Unix()) {
until := time.Unix(int64(u.PremiumUntil), 0)
promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
}
if r.deps.PremiumPromo != nil {
catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx)
if err != nil {
return nil, internalErr()
}
if found {
if len(catalog.VideoSections) != len(catalog.Videos) {
return nil, internalErr()
}
promo.VideoSections = append([]string(nil), catalog.VideoSections...)
promo.Videos = tgDocuments(catalog.Videos)
}
}
return promo, nil
}

View file

@ -0,0 +1,176 @@
package rpc
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
type staticPremiumPromoService struct {
catalog domain.PremiumPromoCatalog
found bool
err error
}
func (s staticPremiumPromoService) PremiumPromo(context.Context) (domain.PremiumPromoCatalog, bool, error) {
out := domain.PremiumPromoCatalog{
VideoSections: append([]string(nil), s.catalog.VideoSections...),
Videos: append([]domain.Document(nil), s.catalog.Videos...),
}
for i := range out.Videos {
out.Videos[i].FileReference = append([]byte(nil), out.Videos[i].FileReference...)
out.Videos[i].Attributes = append([]domain.DocumentAttribute(nil), out.Videos[i].Attributes...)
out.Videos[i].Thumbs = append([]domain.PhotoSize(nil), out.Videos[i].Thumbs...)
}
return out, s.found, s.err
}
func TestHelpGetPremiumPromoReturnsSeededCatalogAcrossExactProfiles(t *testing.T) {
const userID int64 = 1000000001
now := time.Date(2026, 7, 26, 8, 0, 0, 0, time.UTC)
user := domain.User{
ID: userID,
AccessHash: 17,
FirstName: "Alice",
PremiumUntil: int(now.Add(48 * time.Hour).Unix()),
}
catalog := premiumPromoRPCTestCatalog()
r := New(Config{}, Deps{
Users: staticUsersService{user: user},
PremiumPromo: staticPremiumPromoService{catalog: catalog, found: true},
}, zaptest.NewLogger(t), fixedClock{now: now})
ctx := WithUserID(context.Background(), userID)
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
result, method := dispatchExactLayerRPCTest(t, r, ctx, profile, &tg.HelpGetPremiumPromoRequest{})
if method != "help.getPremiumPromo" {
t.Fatalf("method = %q", method)
}
promo, ok := dispatchCanonicalValue(result).(*tg.HelpPremiumPromo)
if !ok {
t.Fatalf("response = %T, want *tg.HelpPremiumPromo", dispatchCanonicalValue(result))
}
if len(promo.VideoSections) != 1 || promo.VideoSections[0] != "no_ads" || len(promo.Videos) != 1 {
t.Fatalf("promo vectors = sections:%v videos:%d", promo.VideoSections, len(promo.Videos))
}
doc, ok := promo.Videos[0].(*tg.Document)
if !ok {
t.Fatalf("video = %T, want *tg.Document", promo.Videos[0])
}
if doc.ID != catalog.Videos[0].ID || doc.DCID != 2 || len(doc.Thumbs) != 1 {
t.Fatalf("document = %+v", doc)
}
thumb, ok := doc.Thumbs[0].(*tg.PhotoSize)
if !ok || thumb.Type != "m" || thumb.Size != 1234 {
t.Fatalf("thumb = %#v", doc.Thumbs[0])
}
if len(promo.PeriodOptions) != 0 {
t.Fatalf("period options = %+v, want no dead purchase entry", promo.PeriodOptions)
}
if !strings.Contains(promo.StatusText, "2026-07-28") {
t.Fatalf("status text = %q, want viewer expiry", promo.StatusText)
}
})
}
}
func TestHelpGetPremiumPromoAuthorizationBotAndFallback(t *testing.T) {
const userID int64 = 1000000001
user := domain.User{ID: userID, AccessHash: 17, FirstName: "Alice"}
r := New(Config{}, Deps{
Users: staticUsersService{user: user},
PremiumPromo: staticPremiumPromoService{},
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)})
if rpcAllowedWithoutAuthorization(tg.HelpGetPremiumPromoRequestTypeID) {
t.Fatal("help.getPremiumPromo must require a fully authorized user")
}
if _, err := r.onHelpGetPremiumPromo(context.Background()); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") {
t.Fatalf("unauthorized error = %v, want AUTH_KEY_UNREGISTERED", err)
}
promo, err := r.onHelpGetPremiumPromo(WithUserID(context.Background(), userID))
if err != nil {
t.Fatalf("fallback response: %v", err)
}
if len(promo.VideoSections) != 0 || len(promo.Videos) != 0 || len(promo.PeriodOptions) != 0 {
t.Fatalf("fallback vectors = %+v", promo)
}
botRouter := New(Config{}, Deps{
Users: staticUsersService{user: domain.User{
ID: userID,
AccessHash: 19,
FirstName: "PromoBot",
Bot: true,
}},
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)})
if _, err := botRouter.onHelpGetPremiumPromo(WithUserID(context.Background(), userID)); !tgerr.Is(err, "BOT_METHOD_INVALID") {
t.Fatalf("bot error = %v, want BOT_METHOD_INVALID", err)
}
}
func TestHelpGetPremiumPromoResponsesDoNotShareMutableDocuments(t *testing.T) {
const userID int64 = 1000000001
catalog := premiumPromoRPCTestCatalog()
r := New(Config{}, Deps{
Users: staticUsersService{user: domain.User{ID: userID}},
PremiumPromo: staticPremiumPromoService{catalog: catalog, found: true},
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_700_000_000, 0)})
ctx := WithUserID(context.Background(), userID)
first, err := r.onHelpGetPremiumPromo(ctx)
if err != nil {
t.Fatal(err)
}
first.VideoSections[0] = "mutated"
firstDoc := first.Videos[0].(*tg.Document)
firstDoc.DCID = 99
firstDoc.FileReference[0] ^= 0xff
second, err := r.onHelpGetPremiumPromo(ctx)
if err != nil {
t.Fatal(err)
}
secondDoc := second.Videos[0].(*tg.Document)
if second.VideoSections[0] != "no_ads" || secondDoc.DCID != 2 || secondDoc.FileReference[0] != 0 {
t.Fatalf("second response inherited mutation: sections=%v doc=%+v", second.VideoSections, secondDoc)
}
}
func premiumPromoRPCTestCatalog() domain.PremiumPromoCatalog {
return domain.PremiumPromoCatalog{
VideoSections: []string{"no_ads"},
Videos: []domain.Document{{
ID: 5814500255441357739,
AccessHash: 5876417653416908580,
FileReference: []byte{0, 1, 2, 3},
Date: 1_654_006_663,
MimeType: "video/mp4",
Size: 2_650_178,
DCID: 2,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrFilename, FileName: "promo.mp4"},
{Kind: domain.DocAttrVideo, W: 720, H: 1070, Duration: 5, SupportsStreaming: true},
{Kind: domain.DocAttrAnimated},
},
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault,
Type: "m",
W: 160,
H: 240,
Size: 1234,
}},
}},
}
}

View file

@ -2,10 +2,13 @@ package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
// registerLangpack 注册 langpack.* RPC handler。
@ -20,7 +23,7 @@ func (r *Router) registerLangpack(d *tlprofile.Dispatcher) {
languages, err := r.langpackLanguages(ctx, langPack)
if err != nil {
return nil, internalErr()
return nil, langpackServiceErr(err)
}
return languages, nil
})
@ -42,7 +45,7 @@ func (r *Router) registerLangpack(d *tlprofile.Dispatcher) {
}
pack, err := r.deps.LangPack.GetLangPack(ctx, langPack, req.LangCode)
if err != nil {
return nil, internalErr()
return nil, langpackServiceErr(err)
}
return tgLangPackDifference(pack), nil
})
@ -52,7 +55,7 @@ func (r *Router) registerLangpack(d *tlprofile.Dispatcher) {
}
pack, err := r.deps.LangPack.GetDifference(ctx, langPackOrClient(ctx, req.LangPack), req.LangCode, req.FromVersion)
if err != nil {
return nil, internalErr()
return nil, langpackServiceErr(err)
}
return tgLangPackDifference(pack), nil
})
@ -62,7 +65,7 @@ func (r *Router) registerLangpack(d *tlprofile.Dispatcher) {
}
pack, err := r.deps.LangPack.GetStrings(ctx, langPackOrClient(ctx, req.LangPack), req.LangCode, req.Keys)
if err != nil {
return nil, internalErr()
return nil, langpackServiceErr(err)
}
return tgLangPackStrings(pack.Strings), nil
})
@ -88,7 +91,7 @@ func (r *Router) langpackLanguage(ctx context.Context, langPack, langCode string
langCode = normalizeLangpackCode(langCode)
languages, err := r.langpackLanguages(ctx, langPack)
if err != nil {
return tg.LangPackLanguage{}, internalErr()
return tg.LangPackLanguage{}, langpackServiceErr(err)
}
for _, lang := range languages {
if strings.ToLower(lang.LangCode) == langCode {
@ -150,3 +153,14 @@ func normalizeLangpackCode(langCode string) string {
code = strings.ReplaceAll(code, "_", "-")
return strings.TrimSuffix(code, "-raw")
}
func langpackServiceErr(err error) error {
switch {
case errors.Is(err, domain.ErrLangPackInvalid):
return langPackInvalidErr()
case errors.Is(err, domain.ErrLangCodeNotSupported):
return langCodeNotSupportedErr()
default:
return internalErr()
}
}

View file

@ -51,6 +51,121 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) {
})
}
func TestLangpackWebAliasCoversEveryRPC(t *testing.T) {
r := newSeededLangpackRouter(t)
ctx := context.Background()
var languagesIn bin.Buffer
if err := (&tg.LangpackGetLanguagesRequest{LangPack: "web"}).Encode(&languagesIn); err != nil {
t.Fatalf("encode getLanguages: %v", err)
}
languages := dispatchLangpackLanguages(t, r, ctx, &languagesIn)
assertHasLangpackLanguage(t, languages, "zh-hans")
lang, err := r.langpackLanguage(ctx, "web", "zh-hans")
if err != nil {
t.Fatalf("getLanguage web alias: %v", err)
}
if lang.LangCode != "zh-hans" {
t.Fatalf("getLanguage web alias = %+v, want zh-hans", lang)
}
var fullIn bin.Buffer
if err := (&tg.LangpackGetLangPackRequest{LangPack: "web", LangCode: "zh-hans"}).Encode(&fullIn); err != nil {
t.Fatalf("encode getLangPack: %v", err)
}
full := dispatchLangpackDifference(t, r, ctx, &fullIn)
if full.LangCode != "zh-hans" || len(full.Strings) != 1 {
t.Fatalf("getLangPack web alias = %+v, want populated zh-hans pack", full)
}
var differenceIn bin.Buffer
if err := (&tg.LangpackGetDifferenceRequest{LangPack: "web", LangCode: "zh-hans", FromVersion: 1}).Encode(&differenceIn); err != nil {
t.Fatalf("encode getDifference: %v", err)
}
difference := dispatchLangpackDifference(t, r, ctx, &differenceIn)
if difference.LangCode != "zh-hans" || difference.FromVersion != 1 || len(difference.Strings) != 1 {
t.Fatalf("getDifference web alias = %+v, want populated zh-hans delta", difference)
}
var stringsIn bin.Buffer
if err := (&tg.LangpackGetStringsRequest{LangPack: "web", LangCode: "zh-hans", Keys: []string{"lng_settings_language"}}).Encode(&stringsIn); err != nil {
t.Fatalf("encode getStrings: %v", err)
}
enc, err := r.Dispatch(ctx, [8]byte{}, 0, &stringsIn)
if err != nil {
t.Fatalf("dispatch getStrings web alias: %v", err)
}
var stringsOut bin.Buffer
if err := enc.Encode(&stringsOut); err != nil {
t.Fatalf("encode getStrings response: %v", err)
}
var stringsVector tg.LangPackStringClassVector
if err := stringsVector.Decode(&stringsOut); err != nil {
t.Fatalf("decode getStrings response: %v", err)
}
if len(stringsVector.Elems) != 1 {
t.Fatalf("getStrings web alias = %+v, want one selected string", stringsVector.Elems)
}
}
func TestLangpackInvalidCatalogErrorsAreMappedForEveryRPC(t *testing.T) {
r := newSeededLangpackRouter(t)
ctx := context.Background()
tests := []struct {
name string
encode func(*bin.Buffer) error
wantErr string
}{
{
name: "getLanguages invalid pack",
encode: func(buf *bin.Buffer) error {
return (&tg.LangpackGetLanguagesRequest{LangPack: "web-invalid"}).Encode(buf)
},
wantErr: "LANG_PACK_INVALID",
},
{
name: "getLanguage invalid pack",
encode: func(buf *bin.Buffer) error {
return (&tg.LangpackGetLanguageRequest{LangPack: "web-invalid", LangCode: "en"}).Encode(buf)
},
wantErr: "LANG_PACK_INVALID",
},
{
name: "getLangPack invalid pack",
encode: func(buf *bin.Buffer) error {
return (&tg.LangpackGetLangPackRequest{LangPack: "web-invalid", LangCode: "en"}).Encode(buf)
},
wantErr: "LANG_PACK_INVALID",
},
{
name: "getDifference unsupported code",
encode: func(buf *bin.Buffer) error {
return (&tg.LangpackGetDifferenceRequest{LangPack: "web", LangCode: "fr", FromVersion: 1}).Encode(buf)
},
wantErr: "LANG_CODE_NOT_SUPPORTED",
},
{
name: "getStrings unsupported code",
encode: func(buf *bin.Buffer) error {
return (&tg.LangpackGetStringsRequest{LangPack: "web", LangCode: "fr", Keys: []string{"key"}}).Encode(buf)
},
wantErr: "LANG_CODE_NOT_SUPPORTED",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var in bin.Buffer
if err := test.encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := r.Dispatch(ctx, [8]byte{}, 0, &in); err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("dispatch error = %v, want %s", err, test.wantErr)
}
})
}
}
func TestLangpackGetLanguage(t *testing.T) {
r := newSeededLangpackRouter(t)
@ -247,6 +362,18 @@ func seededLangPackService(t testing.TB) LangPackService {
{Key: "TranslateLanguageFA", Value: "فارسی"},
},
},
{
LangPack: "webk",
LangCode: "en",
Version: 2,
Strings: []domain.LangPackString{{Key: "lng_settings_language", Value: "Language"}},
},
{
LangPack: "webk",
LangCode: "zh-hans",
Version: 2,
Strings: []domain.LangPackString{{Key: "lng_settings_language", Value: "语言"}},
},
} {
if err := store.UpsertPack(ctx, pack); err != nil {
t.Fatalf("seed %s/%s: %v", pack.LangPack, pack.LangCode, err)
@ -272,6 +399,23 @@ func dispatchLangpackLanguages(t *testing.T, r *Router, ctx context.Context, in
return langs.Elems
}
func dispatchLangpackDifference(t *testing.T, r *Router, ctx context.Context, in *bin.Buffer) tg.LangPackDifference {
t.Helper()
enc, err := r.Dispatch(ctx, [8]byte{}, 0, in)
if err != nil {
t.Fatalf("dispatch langpack difference: %v", err)
}
var out bin.Buffer
if err := enc.Encode(&out); err != nil {
t.Fatalf("encode langpack difference: %v", err)
}
var diff tg.LangPackDifference
if err := diff.Decode(&out); err != nil {
t.Fatalf("decode langpack difference: %v", err)
}
return diff
}
func assertHasLangpackLanguage(t *testing.T, langs []tg.LangPackLanguage, code string) {
t.Helper()
for _, lang := range langs {

View file

@ -57,13 +57,17 @@ const layerRPCReplayRestoreTimeout = 5 * time.Second
// touching auth/session stores. The MTProto edge must call it before acquiring
// an RPC flight/cache slot or scheduling business work.
func (r *Router) AdmitLayer(profile tlprofile.Profile, b *bin.Buffer, limits tlprofile.Limits) (tlprofile.Admission, error) {
return r.AdmitLayerWithOptions(profile, b, tlprofile.AdmissionOptions{Limits: limits})
}
func (r *Router) AdmitLayerWithOptions(profile tlprofile.Profile, b *bin.Buffer, options tlprofile.AdmissionOptions) (tlprofile.Admission, error) {
if r == nil || r.dispatcher == nil {
return tlprofile.Admission{}, internalErr()
}
if b == nil {
return tlprofile.Admission{}, inputRequestInvalidErr()
}
return r.dispatcher.Admit(profile, b, limits)
return r.dispatcher.AdmitWithOptions(profile, b, options)
}
// AdmitDefaultLayer admits a request using an inherited auth-key profile as
@ -71,13 +75,17 @@ func (r *Router) AdmitLayer(profile tlprofile.Profile, b *bin.Buffer, limits tlp
// same wrapper chain to correct that default. Generated admission preserves
// the distinction through EffectiveProfile and ProfileEvidence.
func (r *Router) AdmitDefaultLayer(profile tlprofile.Profile, b *bin.Buffer, limits tlprofile.Limits) (tlprofile.Admission, error) {
return r.AdmitDefaultLayerWithOptions(profile, b, tlprofile.AdmissionOptions{Limits: limits})
}
func (r *Router) AdmitDefaultLayerWithOptions(profile tlprofile.Profile, b *bin.Buffer, options tlprofile.AdmissionOptions) (tlprofile.Admission, error) {
if r == nil || r.dispatcher == nil {
return tlprofile.Admission{}, internalErr()
}
if b == nil {
return tlprofile.Admission{}, inputRequestInvalidErr()
}
return r.dispatcher.AdmitDefault(profile, b, limits)
return r.dispatcher.AdmitDefaultWithOptions(profile, b, options)
}
// registerAndroidLayerRPCAdapter installs the only client-private schema seam.
@ -191,10 +199,14 @@ func (r *Router) PrepareAdmittedReplay(
// a closed terminal whose complete request and result wire graphs were proven
// invariant across every generated profile. The latter never freezes a layer.
func (r *Router) AdmitUnprofiled(b *bin.Buffer, limits tlprofile.Limits) (tlprofile.Admission, error) {
return r.AdmitUnprofiledWithOptions(b, tlprofile.AdmissionOptions{Limits: limits})
}
func (r *Router) AdmitUnprofiledWithOptions(b *bin.Buffer, options tlprofile.AdmissionOptions) (tlprofile.Admission, error) {
if r == nil || r.dispatcher == nil {
return tlprofile.Admission{}, internalErr()
}
return r.dispatcher.AdmitUnprofiled(b, limits)
return r.dispatcher.AdmitUnprofiledWithOptions(b, options)
}
// DispatchAdmitted executes one generated admission lease. invokeAfterMsg(s)

View file

@ -586,7 +586,7 @@ func TestLayerAdmissionFieldPoliciesCoverEveryRoutableProfile(t *testing.T) {
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
limits := tlprofile.Limits{MaxVectorElements: 8 << 10}
for profile := tlprofile.Profile225; profile <= tlprofile.Profile227; profile++ {
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
for _, tc := range cases {
tc := tc
if _, available := tlprofile.WireID(profile, tc.method); !available {

View file

@ -6,6 +6,8 @@ import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/links"
)
// 服务端自动实体检测:@mention / #hashtag / $cashtag / bot command。
@ -24,16 +26,15 @@ import (
// augmentAutoEntities 在客户端已发实体基础上补充服务端检测的自动实体。补充项与任何
// 已有实体(客户端富文本意图实体或先补入的自动实体)区间相交时丢弃,避免把 mention/
// hashtag 打进 code/pre/textUrl/已有 mentionName 内部或彼此重叠(对齐官方不重复打实体)。
// 客户端已带 url/textUrl 实体(自行检测过,如 DrKLO)时跳过 url 检测,沿用既有口径。
// 客户端实体保持在前(超过上限裁剪时优先保留),结果裁剪到实体上限。
func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass {
// URL 按跨度逐条补缺,不能因客户端带了某一条 HTTP URL 就跳过同消息中客户端不认识的
// app-link。客户端实体保持在前(超过上限裁剪时优先保留),结果裁剪到实体上限。
func augmentAutoEntities(message string, entities []tg.MessageEntityClass, appLinks links.AppLinkBuilder) []tg.MessageEntityClass {
// 快路径:绝大多数消息不含任何可自动识别的触发字符。单次 ContainsAny 扫描即短路返回,
// 跳过下面各检测器对全文的扫描与区间分配(纯文本发送零额外开销)。所有 http(s) 链接
// 都含 '/',故 "@#$/" 一并覆盖 url 检测;email/phone 未实现故不在触发集内。
if message == "" || !strings.ContainsAny(message, "@#$/") {
return entities
}
hasClientURL := false
type interval struct{ start, end int }
occupied := make([]interval, 0, len(entities)+8)
for _, e := range entities {
@ -41,10 +42,6 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
off := e.GetOffset()
occupied = append(occupied, interval{off, off + ln})
}
switch e.(type) {
case *tg.MessageEntityURL, *tg.MessageEntityTextURL:
hasClientURL = true
}
}
overlaps := func(s, e int) bool {
for _, iv := range occupied {
@ -75,9 +72,9 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
}
// URL 跨度始终计算并加入排除区(occupied),使 @mention/#hashtag 等不会落进 URL 路径内部
// (如 https://t.me/@scam 的 @scam,既不符官方语义也是钓鱼风险);但仅在客户端未带任何
// url/textUrl 实体时才作为实体下发,沿用 all-or-nothing(DrKLO 一带即全带;TDesktop 不带、依赖服务端)。
for _, u := range detectURLEntities(message) {
// (如 https://t.me/@scam 的 @scam,既不符官方语义也是钓鱼风险)。逐跨度补缺可覆盖
// “客户端带 HTTP entity、但不认识 telesrv://”的混合消息,同时仍避免重复实体。
for _, u := range detectURLEntities(message, appLinks) {
ln := u.GetLength()
if ln <= 0 {
continue
@ -87,7 +84,7 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
continue
}
occupied = append(occupied, interval{off, off + ln})
if !hasClientURL && len(entities)+len(extra) < maxMessageEntityCount {
if len(entities)+len(extra) < maxMessageEntityCount {
extra = append(extra, u)
}
}
@ -114,6 +111,10 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
return append(out, extra...)
}
func (r *Router) augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass {
return augmentAutoEntities(message, entities, r.appLinks)
}
// isWordRune 判定「单词字符」(用于实体前导边界:前一个字符是单词字符时不是新实体起点,
// 借此排除 email 的 local@domain、路径里的 and/or 等)。
func isWordRune(r rune) bool {

View file

@ -228,6 +228,7 @@ func (r *Router) attachMenuUsers(ctx context.Context, viewerID int64, ids []int6
}
out = append(out, tgUser)
}
r.applyPeerReadModels(ctx, viewerID, out, nil)
return out
}

View file

@ -455,6 +455,7 @@ func (r *Router) onMessagesGetPreparedInlineMessage(ctx context.Context, req *tg
out.Users = append(out.Users, r.tgUser(u))
}
}
r.applyPeerReadModels(ctx, userID, out.Users, nil)
return out, nil
}

View file

@ -42,11 +42,13 @@ func (r *Router) onMessagesGetSavedHistory(ctx context.Context, req *tg.Messages
if req.Hash != 0 {
return &tg.MessagesMessagesNotModified{Count: 0}, nil
}
return &tg.MessagesMessages{
result := &tg.MessagesMessages{
Messages: []tg.MessageClass{},
Chats: r.savedHistoryChats(ctx, userID, hasParent, parentPeer, req.Peer),
Users: []tg.UserClass{},
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
// parent_peer = monoforum:返回该订阅者(req.Peer)在频道私信内的历史。
return r.monoforumSavedHistory(ctx, userID, mono, savedPeer, req.Limit, req.OffsetID)
@ -83,6 +85,7 @@ func (r *Router) onMessagesGetSavedHistory(ctx context.Context, req *tg.Messages
m.Chats = mergeTGChats(m.Chats, chats)
}
}
r.applyPeerReadModelsToMessages(ctx, userID, out)
return out, nil
}
@ -194,6 +197,7 @@ func (r *Router) onMessagesGetCommonChats(ctx context.Context, req *tg.MessagesG
for _, ch := range common.Channels {
chats = append(chats, tgChannelChatMin(userID, ch))
}
r.applyUsernamesToPeerObjects(ctx, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}

View file

@ -18,7 +18,7 @@ const (
maxReactionVector = 16
maxReactionListOffset = 128
maxReportOptionLength = 32
maxReportCommentLength = 1024
maxReportCommentLength = domain.MaxModerationCommentRunes
maxReportRandomIDLength = 128
maxReadMetrics = 100
maxBusinessConnIDLength = 128

View file

@ -19,10 +19,6 @@ type accountPaidReactionPrivacyService interface {
SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error)
}
type messageReactionUpdateRecorder interface {
RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error)
}
type messagePollUpdateRecorder interface {
RecordMessagePoll(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error)
}

View file

@ -8,6 +8,7 @@ import (
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appprivacy "telesrv/internal/app/privacy"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
@ -119,6 +120,99 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
}
}
func TestMessagesCreateChatFiltersPrivacyBeforeMembershipWritesRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
allowed, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Allowed"})
if err != nil {
t.Fatalf("create allowed user: %v", err)
}
denied, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550001033", FirstName: "Denied"})
if err != nil {
t.Fatalf("create denied user: %v", err)
}
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, denied.ID, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set denied invite privacy: %v", err)
}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Privacy: privacy,
}, zaptest.NewLogger(t), clock.System)
invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: allowed.ID, AccessHash: allowed.AccessHash},
&tg.InputUser{UserID: denied.ID, AccessHash: denied.AccessHash},
},
Title: "Privacy Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
if len(invited.MissingInvitees) != 1 || invited.MissingInvitees[0].UserID != denied.ID {
t.Fatalf("missing invitees = %+v, want denied user %d", invited.MissingInvitees, denied.ID)
}
updates, ok := invited.Updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", invited.Updates)
}
var channel *tg.Channel
for _, chat := range updates.Chats {
if candidate, ok := chat.(*tg.Channel); ok {
channel = candidate
break
}
}
if channel == nil || channel.ParticipantsCount != 2 {
t.Fatalf("channel = %#v, want owner + allowed only", channel)
}
for _, update := range updates.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
service, ok := newMessage.Message.(*tg.MessageService)
if !ok {
continue
}
add, ok := service.Action.(*tg.MessageActionChatAddUser)
if !ok {
continue
}
if len(add.Users) != 1 || add.Users[0] != allowed.ID {
t.Fatalf("invite action users = %v, want allowed user %d only", add.Users, allowed.ID)
}
}
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
list := participants.(*tg.ChannelsChannelParticipants)
if list.Count != 2 {
t.Fatalf("participant count = %d, want 2", list.Count)
}
for _, user := range list.Users {
if got, ok := user.(*tg.User); ok && got.ID == denied.ID {
t.Fatalf("denied user %d was written as a member", denied.ID)
}
}
}
func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
tests := []struct {
name string

View file

@ -34,10 +34,11 @@ func (r *Router) onMessagesDeleteMessages(ctx context.Context, req *tg.MessagesD
return nil, internalErr()
}
self := res.Self()
if len(self.MessageIDs) == 0 || self.Event.Pts == 0 {
pts, ptsCount := self.AffectedPts()
if len(self.MessageIDs) == 0 || pts == 0 {
return r.affectedMessages(ctx, authKeyID, userID)
}
return &tg.MessagesAffectedMessages{Pts: self.Event.Pts, PtsCount: self.Event.PtsCount}, nil
return &tg.MessagesAffectedMessages{Pts: pts, PtsCount: ptsCount}, nil
}
func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDeleteHistoryRequest) (*tg.MessagesAffectedHistory, error) {
@ -68,21 +69,18 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
return nil, channelDeleteErr(err)
}
if res.Event.Pts != 0 {
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
})
return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil
}
if res.AvailableMinID > 0 {
event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID)
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID)
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event)
if res.AvailableMinChanged && res.AvailableMinID > 0 {
updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID)
r.pushUserUpdates(ctx, userID, updates)
if event.Pts != 0 {
return &tg.MessagesAffectedHistory{Pts: event.Pts, PtsCount: event.PtsCount, Offset: res.Offset}, nil
}
}
return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil
// messages.affectedHistory.pts is the caller's account-state snapshot.
// The local channel clear itself consumes no account/channel pts.
return r.affectedHistory(ctx, authKeyID, userID, res.Offset)
}
if peer.Type != domain.PeerTypeUser {
return nil, peerIDInvalidErr()
@ -109,12 +107,13 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
return nil, internalErr()
}
self := res.Self()
if len(self.MessageIDs) == 0 || self.Event.Pts == 0 {
pts, ptsCount := self.AffectedPts()
if pts == 0 {
return r.affectedHistory(ctx, authKeyID, userID, 0)
}
return &tg.MessagesAffectedHistory{
Pts: self.Event.Pts,
PtsCount: self.Event.PtsCount,
Pts: pts,
PtsCount: ptsCount,
Offset: res.Offset,
}, nil
}

View file

@ -7,11 +7,74 @@ import (
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestMessagesDeleteHistoryChannelLocalClearReturnsAccountStateWithoutPTS(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 7,
Title: "local clear no pts",
Megagroup: true,
Date: 1_700_002_100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 7,
ChannelID: created.Channel.ID,
RandomID: 210_001,
Message: "clear locally",
Date: 1_700_002_101,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
stateBefore, err := updateSvc.CurrentState(ctx, 7)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
sessions := &captureSessions{}
r := New(Config{}, Deps{
Channels: appchannels.NewService(channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
affected, err := r.onMessagesDeleteHistory(WithUserID(ctx, 7), &tg.MessagesDeleteHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
MaxID: sent.Message.ID,
})
if err != nil {
t.Fatalf("messages.deleteHistory local channel: %v", err)
}
if affected.Pts != stateBefore.Pts || affected.PtsCount != 0 || affected.Offset != 0 {
t.Fatalf("affected history = %+v, want account pts=%d pts_count=0 offset=0", affected, stateBefore.Pts)
}
stateAfter, err := updateSvc.CurrentState(ctx, 7)
if err != nil {
t.Fatalf("account state after clear: %v", err)
}
if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("local channel clear advanced account pts: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
}
pushed := sessions.snapshot()
updates, ok := pushed.message.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("pushed local clear = %T %+v, want one available update", pushed.message, pushed.message)
}
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != created.Channel.ID || available.AvailableMinID != sent.Message.ID {
t.Fatalf("available update = %#v, want channel=%d min=%d", updates.Updates[0], created.Channel.ID, sent.Message.ID)
}
}
func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
@ -149,6 +212,8 @@ func TestMessagesDeleteHistoryPassesJustClearContext(t *testing.T) {
UserID: userID,
MessageIDs: []int{1, 2, 3},
Event: domain.UpdateEvent{Pts: 12, PtsCount: 3},
Pts: 14,
PtsCount: 5,
}},
}}
r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System)
@ -171,8 +236,8 @@ func TestMessagesDeleteHistoryPassesJustClearContext(t *testing.T) {
if !ok {
t.Fatalf("response = %T, want *tg.MessagesAffectedHistory", enc)
}
if got.Pts != 12 || got.PtsCount != 3 {
t.Fatalf("affected = %+v, want pts=12 pts_count=3", got)
if got.Pts != 14 || got.PtsCount != 5 {
t.Fatalf("affected = %+v, want aggregate pts=14 pts_count=5", got)
}
reqGot := messages.deleteHistoryReq
if reqGot.OwnerUserID != userID || reqGot.Peer.ID != peerID || reqGot.MaxID != 15 || !reqGot.JustClear || !reqGot.Revoke || reqGot.OriginSessionID != 88 || reqGot.OriginAuthKeyID != authKeyID {

View file

@ -50,10 +50,11 @@ func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDr
if draft.TopMessageID > 0 {
update.SetTopMsgID(draft.TopMessageID)
}
users, chats := r.peerObjectsForDraftUpdate(ctx, userID, peer)
updates := &tg.Updates{
Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded),
Users: r.usersForDraftUpdate(ctx, userID, peer),
Chats: r.chatsForDraftUpdate(ctx, userID, peer),
Users: users,
Chats: chats,
Date: date,
Seq: 0,
}
@ -95,8 +96,7 @@ func (r *Router) onMessagesGetAllDrafts(ctx context.Context) (tg.UpdatesClass, e
return nil, dialogDraftErr(err)
}
updates := make([]tg.UpdateClass, 0, len(drafts))
users := r.usersForDrafts(ctx, userID, drafts)
chats := r.chatsForDrafts(ctx, userID, drafts)
users, chats := r.peerObjectsForDrafts(ctx, userID, drafts)
for _, draft := range drafts {
peer := tgPeer(draft.Peer)
if peer == nil {
@ -141,10 +141,11 @@ func (r *Router) onMessagesClearAllDrafts(ctx context.Context) (bool, error) {
}
}
r.bookkeepAuxPtsForCurrentSession(ctx, events...)
users, chats := r.peerObjectsForDrafts(ctx, userID, drafts)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: updates,
Users: r.usersForDrafts(ctx, userID, drafts),
Chats: r.chatsForDrafts(ctx, userID, drafts),
Users: users,
Chats: chats,
Date: date,
Seq: 0,
})
@ -346,6 +347,20 @@ func (r *Router) chatsForDrafts(ctx context.Context, userID int64, drafts []doma
return chats
}
func (r *Router) peerObjectsForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) ([]tg.UserClass, []tg.ChatClass) {
users := r.usersForDraftUpdate(ctx, userID, peer)
chats := r.chatsForDraftUpdate(ctx, userID, peer)
r.applyUsernamesToPeerObjects(ctx, users, chats)
return users, chats
}
func (r *Router) peerObjectsForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) ([]tg.UserClass, []tg.ChatClass) {
users := r.usersForDrafts(ctx, userID, drafts)
chats := r.chatsForDrafts(ctx, userID, drafts)
r.applyUsernamesToPeerObjects(ctx, users, chats)
return users, chats
}
func dialogDraftErr(err error) error {
switch {
case err == nil:
@ -382,10 +397,11 @@ func (r *Router) clearDraftAfterSend(ctx context.Context, userID int64, peer dom
}
recorded := r.recordDraftMessageEvent(ctx, userID, peer, topMessageID, &date)
r.bookkeepAuxPtsForCurrentSession(ctx, recorded)
users, chats := r.peerObjectsForDraftUpdate(ctx, userID, peer)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded),
Users: r.usersForDraftUpdate(ctx, userID, peer),
Chats: r.chatsForDraftUpdate(ctx, userID, peer),
Users: users,
Chats: chats,
Date: date,
Seq: 0,
})
@ -534,9 +550,16 @@ func (r *Router) onMessagesGetPeerSettings(ctx context.Context, input tg.InputPe
}
r.peerSettingsProjectionCache.StoreIfEpoch(userID, peer, settings, loadEpoch)
}
users := r.peerSettingsUsers(ctx, userID, input)
// messages.getPeerSettings is requested while a chat is opened and official
// clients feed the returned peer objects into the same cache as getDialogs and
// getFullUser. Keep these auxiliary objects on the common response-boundary
// projection path: a full User without bot_verification_icon is authoritative
// to TDesktop/Android/TWeb and would clear a badge learned from another RPC.
r.applyPeerReadModels(ctx, userID, users, nil)
return &tg.MessagesPeerSettings{
Settings: tgPeerSettings(settings),
Users: r.peerSettingsUsers(ctx, userID, input),
Users: users,
}, nil
}

View file

@ -50,7 +50,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if hasMessage && richMessage == nil {
// 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
entities = augmentAutoEntities(message, entities)
entities = r.augmentAutoEntities(message, entities)
}
userID, _, err := r.currentUserID(ctx)
if err != nil {

View file

@ -62,7 +62,7 @@ func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22},
ID: 3,
}
req.SetMessage("edited")
req.SetMessage("edited telesrv://resolve?domain=Alice")
req.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 6}})
var in bin.Buffer
if err := req.Encode(&in); err != nil {
@ -85,14 +85,16 @@ func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
t.Fatalf("edit update = %#v, want pts=7 count=1", got.Updates[0])
}
msg, ok := edit.Message.(*tg.Message)
if !ok || msg.ID != 3 || msg.Message != "edited" {
t.Fatalf("edited message = %#v, want id=3 text edited", edit.Message)
if !ok || msg.ID != 3 || msg.Message != "edited telesrv://resolve?domain=Alice" {
t.Fatalf("edited message = %#v, want id=3 text with app-link", edit.Message)
}
if messages.editReq.OwnerUserID != userID || messages.editReq.Peer.ID != peerID || messages.editReq.ID != 3 || messages.editReq.OriginAuthKeyID != authKeyID || messages.editReq.OriginSessionID != 77 {
t.Fatalf("edit request = %+v, want owner peer message id and origin", messages.editReq)
}
if len(messages.editReq.Entities) != 1 || messages.editReq.Entities[0].Type != domain.MessageEntityBold {
t.Fatalf("edit entities = %+v, want bold", messages.editReq.Entities)
if len(messages.editReq.Entities) != 2 || messages.editReq.Entities[0].Type != domain.MessageEntityBold ||
messages.editReq.Entities[1].Type != domain.MessageEntityURL || messages.editReq.Entities[1].Offset != 7 ||
messages.editReq.Entities[1].Length != utf16CodeUnitLen("telesrv://resolve?domain=Alice") {
t.Fatalf("edit entities = %+v, want bold plus configured app-link", messages.editReq.Entities)
}
}

View file

@ -207,7 +207,7 @@ func (r *Router) onMessagesDeleteTopicHistory(ctx context.Context, req *tg.Messa
return nil, forumTopicError(err)
}
if res.Event.Pts != 0 {
r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{tgChannelUpdate(viewerUserID, res.Event)},
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)},

View file

@ -64,6 +64,39 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
return nil, internalErr()
}
}
suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
var mono domain.Channel
var monoforum, monoforumAdmin bool
if toPeer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, toPeer.ID)
switch {
case err == nil:
monoforum = true
case !errors.Is(err, domain.ErrChannelInvalid):
return nil, internalErr()
}
}
if hasSuggestedPost && !monoforum {
return nil, suggestedPostPeerInvalidErr()
}
if monoforum {
suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost)
if err != nil {
return nil, err
}
return r.forwardMessagesToMonoforum(
ctx,
userID,
toPeer,
mono,
monoforumAdmin,
req,
idempotencyFingerprints,
suggestedPost,
topMsgID,
topMsgIDSet,
)
}
immediate := req.ScheduleDate == 0 || scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix()))
replays := make([]outgoingReplayLookup, len(req.ID))
absentIndexes := make([]int, 0, len(req.ID))
@ -110,6 +143,14 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if err != nil {
return nil, err
}
if toPeer.Type == domain.PeerTypeUser {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, toPeer.ID, req.AllowPaidStars, len(absentIndexes)); err != nil {
return nil, err
}
}
absentIDs := make([]int, len(absentIndexes))
absentRandomIDs := make([]int64, len(absentIndexes))
for i, originalIndex := range absentIndexes {
@ -299,6 +340,132 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
return nil, peerIDInvalidErr()
}
func (r *Router) forwardMessagesToMonoforum(
ctx context.Context,
userID int64,
toPeer domain.Peer,
mono domain.Channel,
monoforumAdmin bool,
req *tg.MessagesForwardMessagesRequest,
idempotencyFingerprints [][]byte,
suggestedPost *domain.SuggestedPost,
topMsgID int,
topMsgIDSet bool,
) (tg.UpdatesClass, error) {
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return nil, scheduleDateInvalidErr()
}
savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo)
if err != nil {
return nil, err
}
replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, toPeer, req.ReplyTo)
if err != nil {
return nil, err
}
// DrKLO currently mirrors the replied suggestion into top_msg_id as well as reply_to.
// Monoforum has no forum root: accept only the redundant equal value and never persist it
// as a topic root.
if topMsgIDSet && topMsgID != 0 && (replyTo == nil || replyTo.MessageID != topMsgID) {
return nil, replyMessageIDInvalidErr()
}
replays := make([]outgoingReplayLookup, len(req.ID))
absentIndexes := make([]int, 0, len(req.ID))
for i := range req.ID {
replay, err := r.lookupChannelSendReplay(
ctx,
userID,
toPeer.ID,
savedPeer,
req.RandomID[i],
idempotencyFingerprints[i],
)
if err != nil {
return nil, err
}
replays[i] = replay
if !replay.found {
absentIndexes = append(absentIndexes, i)
}
}
if len(absentIndexes) == 0 {
results := make([]tg.UpdatesClass, 0, len(replays))
for _, replay := range replays {
results = append(results, r.monoforumSendUpdates(ctx, userID, mono, savedPeer, replay.channel))
}
return combineSendUpdates(results), nil
}
if err := r.checkSendRateLimit(ctx, userID, len(absentIndexes)); err != nil {
return nil, err
}
checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer)
if err != nil {
return nil, err
}
if checkedPeer != toPeer {
return nil, peerIDInvalidErr()
}
absentIDs := make([]int, len(absentIndexes))
absentRandomIDs := make([]int64, len(absentIndexes))
for i, originalIndex := range absentIndexes {
absentIDs[i] = req.ID[originalIndex]
absentRandomIDs[i] = req.RandomID[originalIndex]
}
fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, absentIDs, absentRandomIDs)
if err != nil {
return nil, err
}
if fromPeer.Type == domain.PeerTypeUser && fromPeer.ID != userID && r.deps.Users != nil {
if _, found, err := r.deps.Users.ByID(ctx, userID, fromPeer.ID); err != nil {
return nil, internalErr()
} else if !found {
return nil, peerIDInvalidErr()
}
}
absentSources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, absentIDs, preloadedSources)
if err != nil {
return nil, messageForwardErr(err)
}
sources := make([]forwardSource, len(req.ID))
for i, originalIndex := range absentIndexes {
sources[originalIndex] = absentSources[i]
}
results := make([]tg.UpdatesClass, 0, len(req.ID))
for i, source := range sources {
if replays[i].found {
results = append(results, r.monoforumSendUpdates(ctx, userID, mono, savedPeer, replays[i].channel))
continue
}
forward := source.forward
if req.DropAuthor {
forward = nil
}
updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{
SavedPeer: savedPeer,
RandomID: req.RandomID[i],
IdempotencyFingerprint: idempotencyFingerprints[i],
IdempotencyPreflighted: replays[i].checked,
Message: source.body,
Entities: source.entities,
Media: source.media,
ReplyTo: replyTo,
Forward: forward,
Silent: req.Silent,
NoForwards: req.Noforwards,
SuggestedPost: suggestedPost,
AllowPaidStars: req.AllowPaidStars,
})
if err != nil {
return nil, err
}
results = append(results, updates)
}
return combineSendUpdates(results), nil
}
func normalizeForwardMessageVectors(ids []int, randomIDs []int64) ([]int, []int64, bool) {
if len(ids) == 0 || len(randomIDs) == 0 {
return nil, nil, false
@ -507,6 +674,15 @@ func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID i
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
return nil, domain.ErrMessageIDInvalid
}
if svc, ok := r.deps.Messages.(PrivateNoForwardsService); ok {
state, err := svc.GetPrivateNoForwards(ctx, userID, fromPeer.ID)
if err != nil {
return nil, err
}
if state.Enabled() {
return nil, domain.ErrChatForwardsRestricted
}
}
byID := make(map[int]domain.Message, len(messages))
for _, msg := range messages {
byID[msg.ID] = msg

View file

@ -14,24 +14,52 @@ func reportResultForOption(option string) (tg.ReportResultClass, error) {
return &tg.ReportResultChooseOption{
Title: "Report",
Options: []tg.MessageReportOption{
{Text: "Spam", Option: []byte("spam")},
{Text: "Scam or spam", Option: []byte("spam")},
{Text: "Violence", Option: []byte("violence")},
{Text: "Illegal goods", Option: []byte("illegal_goods")},
{Text: "Pornography", Option: []byte("pornography")},
{Text: "Child abuse", Option: []byte("child_abuse")},
{Text: "Personal data", Option: []byte("personal_data")},
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
{Text: "Personal details", Option: []byte("personal_details")},
{Text: "Copyright", Option: []byte("copyright")},
{Text: "Fake or impersonation", Option: []byte("fake")},
{Text: "Other", Option: []byte("other")},
},
}, nil
case "other":
return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil
case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment":
case "spam", "violence", "pornography", "child_abuse", "illegal_drugs",
"personal_details", "copyright", "fake", "other:comment":
return &tg.ReportResultReported{}, nil
default:
return nil, tgerr.New(400, "OPTION_INVALID")
}
}
func moderationReasonForReportOption(option string) (domain.ModerationReason, bool) {
switch option {
case "spam":
return domain.ModerationReasonSpam, true
case "violence":
return domain.ModerationReasonViolence, true
case "pornography":
return domain.ModerationReasonPornography, true
case "child_abuse":
return domain.ModerationReasonChildAbuse, true
case "illegal_drugs":
return domain.ModerationReasonIllegalDrugs, true
case "personal_details":
return domain.ModerationReasonPersonalDetails, true
case "copyright":
return domain.ModerationReasonCopyright, true
case "fake":
return domain.ModerationReasonFake, true
case "other:comment":
return domain.ModerationReasonOther, true
default:
return "", false
}
}
func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass {
switch peer.Type {
case domain.PeerTypeUser:
@ -384,10 +412,8 @@ func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest)
return mediaInvalidErr()
case req.AllowPaidStars < 0:
return starsAmountInvalidErr()
case req.AllowPaidStars > 0 || req.AllowPaidFloodskip:
case req.AllowPaidFloodskip:
return paymentUnsupportedErr()
case !req.SuggestedPost.Zero():
return suggestedPostPeerInvalidErr()
default:
return nil
}

View file

@ -197,35 +197,50 @@ func (r *Router) onMessagesGetSearchCounters(ctx context.Context, req *tg.Messag
if err != nil {
return nil, err
}
needsPinned := false
needsMedia := false
for _, filter := range req.Filters {
if filter == nil {
continue
}
if _, ok := filter.(*tg.InputMessagesFilterPinned); ok {
needsPinned = true
continue
}
if len(mediaCategoriesForFilter(filter)) > 0 {
needsMedia = true
}
}
pinnedCount := 0
mediaCounts := domain.MediaCategoryCounts{}
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
// 只读 PinnedMessageID(Channel 字段):走轻量 ResolveChannel,省 dialog/读态/boost 查询。
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return nil, err
if needsPinned {
switch {
case peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil:
history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{
ChannelID: peer.ID,
PinnedOnly: true,
NeedTotalCount: true,
CountOnly: true,
})
if err != nil {
return nil, err
}
pinnedCount = history.Count
case peer.Type == domain.PeerTypeUser && r.deps.Messages != nil:
list, err := r.deps.Messages.Search(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
PinnedOnly: true,
Limit: 1,
NeedTotalCount: true,
})
if err != nil {
return nil, err
}
pinnedCount = list.Count
}
if view.Channel.PinnedMessageID > 0 {
pinnedCount = 1
}
counts, err := r.mediaCountsForPeer(ctx, userID, peer)
if err != nil {
return nil, err
}
mediaCounts = counts
}
if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil {
list, err := r.deps.Messages.Search(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
PinnedOnly: true,
Limit: 1,
NeedTotalCount: true,
})
if err != nil {
return nil, err
}
pinnedCount = list.Count
if needsMedia {
counts, err := r.mediaCountsForPeer(ctx, userID, peer)
if err != nil {
return nil, err
@ -283,11 +298,13 @@ func (r *Router) onMessagesGetReplies(ctx context.Context, req *tg.MessagesGetRe
}
return r.tgChannelHistoryMessages(ctx, userID, r.enrichChannelHistory(ctx, userID, replies)), nil
}
return &tg.MessagesMessages{
result := &tg.MessagesMessages{
Messages: []tg.MessageClass{},
Chats: r.chatsForInputPeer(ctx, userID, req.Peer),
Users: []tg.UserClass{},
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
func (r *Router) onMessagesGetDiscussionMessage(ctx context.Context, req *tg.MessagesGetDiscussionMessageRequest) (*tg.MessagesDiscussionMessage, error) {
@ -475,11 +492,13 @@ func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessag
out = append(out, tgMessage(msg))
}
chats := r.chatsForMessageUpdates(ctx, userID, found)
return &tg.MessagesMessages{
result := &tg.MessagesMessages{
Messages: out,
Users: r.usersForMessageUpdates(ctx, userID, found),
Chats: chats,
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
// onMessagesGetRichMessage 返回单条消息的完整富文本(Layer 227 richMessage)。消息列表
@ -520,11 +539,13 @@ func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesG
if len(out) == 0 {
out = append(out, &tg.MessageEmpty{ID: req.ID})
}
return &tg.MessagesMessages{
result := &tg.MessagesMessages{
Messages: out,
Users: r.usersForMessageUpdates(ctx, userID, found),
Chats: r.chatsForMessageUpdates(ctx, userID, found),
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) {
@ -577,7 +598,9 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
return nil, err
}
if emptyCommunitySearch {
return appendCommunitySearchChat(&tg.MessagesMessages{}, communityView), nil
result := appendCommunitySearchChat(&tg.MessagesMessages{}, communityView)
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
var private domain.MessageList
if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil {
@ -604,7 +627,9 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
}
}
if req.UsersOnly || r.deps.Channels == nil {
return appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView), nil
result := appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView)
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{
Query: query,
@ -766,7 +791,7 @@ func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesG
}, true
}
func (r *Router) messageFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest) domain.MessageFilter {
func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int64, req *tg.MessagesSearchRequest) (domain.MessageFilter, error) {
limit := req.Limit
if limit > 500 {
limit = 500
@ -774,6 +799,8 @@ func (r *Router) messageFilterFromSearchRequest(userID int64, req *tg.MessagesSe
filter := domain.MessageFilter{
Query: req.Q,
OffsetID: req.OffsetID,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: limit,
MaxID: req.MaxID,
@ -786,11 +813,52 @@ func (r *Router) messageFilterFromSearchRequest(userID int64, req *tg.MessagesSe
filter.HasPeer = true
filter.Peer = peer
}
return filter
savedReactions, hasSavedReactions := req.GetSavedReaction()
// An empty optional vector carries no reaction-filtering semantics. Some TL
// clients emit flags.3 with a zero-length vector on ordinary peer searches.
// Keep the wire presence intact at the TL edge, but only apply Saved
// Messages scope and reaction validation when the vector has values.
hasSavedReactionFilter := hasSavedReactions && len(savedReactions) > 0
savedPeerInput, hasSavedPeer := req.GetSavedPeerID()
if hasSavedReactionFilter || hasSavedPeer {
if !filter.HasPeer ||
filter.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
return domain.MessageFilter{}, peerIDInvalidErr()
}
}
if hasSavedPeer {
if savedPeerInput == nil {
return domain.MessageFilter{}, peerIDInvalidErr()
}
savedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeerInput)
if err != nil {
return domain.MessageFilter{}, err
}
filter.SavedPeer = savedPeer
}
if hasSavedReactionFilter {
if len(savedReactions) > maxReactionVector {
return domain.MessageFilter{}, reactionInvalidErr()
}
seen := make(map[string]struct{}, len(savedReactions))
for _, item := range savedReactions {
reaction, err := domainMessageReactionFromTL(item)
if err != nil {
return domain.MessageFilter{}, err
}
if _, ok := seen[reaction.Key()]; ok {
continue
}
seen[reaction.Key()] = struct{}{}
filter.SavedReactions = append(filter.SavedReactions, reaction)
}
}
return filter, nil
}
func (r *Router) channelHistoryFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest, channelID int64) (domain.ChannelHistoryFilter, bool) {
limit := req.Limit
countOnly := limit == 0
if limit <= 0 || limit > 100 {
limit = 100
}
@ -799,14 +867,17 @@ func (r *Router) channelHistoryFilterFromSearchRequest(userID int64, req *tg.Mes
Query: req.Q,
PinnedOnly: messagesSearchFilterPinned(req.Filter),
MusicOnly: messagesSearchFilterMusic(req.Filter),
OffsetID: req.OffsetID,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: limit,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
MaxID: req.MaxID,
MinID: req.MinID,
Hash: req.Hash,
NeedTotalCount: countOnly ||
(req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0),
CountOnly: countOnly,
OffsetID: req.OffsetID,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: limit,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
MaxID: req.MaxID,
MinID: req.MinID,
Hash: req.Hash,
}
if req.FromID != nil {
from, ok := r.domainPeerFromInputPeer(userID, req.FromID)

View file

@ -225,6 +225,9 @@ func TestMessagesSearchChannelPeerReturnsSingleCopyMessages(t *testing.T) {
Filter: &tg.InputMessagesFilterPhotos{},
Limit: 0,
}
// Match DrKLO's ordinary messages.search wire shape: flags.3 is present
// even though the saved_reaction vector is empty.
mediaCountReq.SetSavedReaction([]tg.ReactionClass{})
in.Reset()
if err := mediaCountReq.Encode(&in); err != nil {
t.Fatalf("encode shared media count search: %v", err)

View file

@ -119,12 +119,14 @@ func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono d
messages = append(messages, item)
}
}
return &tg.MessagesMessagesSlice{
result := &tg.MessagesMessagesSlice{
Count: hist.Count,
Messages: messages,
Chats: r.monoforumChats(ctx, userID, mono),
Users: r.monoforumSubscriberUsers(ctx, userID, nil, hist.Messages),
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身直接投影,

View file

@ -34,9 +34,11 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
verify := newFakeBotVerifications()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Users: appusers.NewService(userStore),
Channels: channelSvc,
BotVerifications: verify,
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000})
@ -65,6 +67,14 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
}
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
parentInput := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
const monoforumIcon = int64(8800025)
monoforumPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}
verify.marks[monoforumPeer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: monoforumPeer,
IconDocumentID: monoforumIcon,
Description: "Verified monoforum peer",
}
// TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。
// 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。
@ -83,6 +93,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
if len(mainHistory.Messages) != 1 {
t.Fatalf("main monoforum history = %d msgs, want only the creation service", len(mainHistory.Messages))
}
assertMessagesEnvelopeBotVerificationIcon(t, mainHistory, monoforumPeer, monoforumIcon)
service, ok := mainHistory.Messages[0].(*tg.MessageService)
if !ok {
t.Fatalf("main monoforum message = %T, want MessageService", mainHistory.Messages[0])
@ -167,6 +178,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
if err != nil {
t.Fatalf("getSavedHistory(monoforum): %v", err)
}
assertMessagesEnvelopeBotVerificationIcon(t, hres, monoforumPeer, monoforumIcon)
var gotMsgs []tg.MessageClass
switch m := hres.(type) {
case *tg.MessagesMessages:
@ -291,7 +303,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
}
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub", RandomID: 555}
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub telesrv://resolve?domain=Owner", RandomID: 555}
subReq.ClearDraft = true
subReq.SetAllowPaidStars(20)
suggestedInput := tg.SuggestedPost{}
@ -313,6 +325,15 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
if message, ok := newMessage.Message.(*tg.Message); ok {
subMessageID = message.ID
subPaidStars, _ = message.GetPaidMessageStars()
var hasAppLink bool
for _, entity := range message.Entities {
if url, ok := entity.(*tg.MessageEntityURL); ok && url.Offset == utf16CodeUnitLen("hi from sub ") && url.Length == utf16CodeUnitLen("telesrv://resolve?domain=Owner") {
hasAppLink = true
}
}
if !hasAppLink {
t.Fatalf("monoforum message missing configured app-link entity: %+v", message.Entities)
}
}
}
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
@ -455,3 +476,331 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
t.Fatalf("suggested_post schedule = %d/%v, want 1700100000/true", scheduleDate, ok)
}
}
// TestMonoforumForwardSuggestedPostAndMessageMetadataPaths 回归 DrKLO 的四条真实路径:
// Add Offer 用 forwardMessages+suggested_post 新建建议,Edit Price/Edit Time 再回复原建议;
// getMessagesViews 与 get/send reaction 必须允许无 member 行的订阅者访问自己的 saved_peer,
// 且不能按猜测 id 跨到另一订阅者的子会话。
func TestMonoforumForwardSuggestedPostAndMessageMetadataPaths(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550004001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sub, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550004002", FirstName: "Sub"})
if err != nil {
t.Fatalf("create sub: %v", err)
}
other, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550004003", FirstName: "Other"})
if err != nil {
t.Fatalf("create other: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Suggested", Broadcast: true, Date: 2000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatalf("enable DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
mono, err := channelStore.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum: %v", err)
}
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
original, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer,
RandomID: 7001, Message: "please publish this", Date: 2001,
})
if err != nil {
t.Fatalf("seed subscriber message: %v", err)
}
otherMessage, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: other.ID,
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: other.ID},
RandomID: 7002, Message: "another subscriber", Date: 2002,
})
if err != nil {
t.Fatalf("seed other subscriber message: %v", err)
}
// DrKLO 会为带 views/replies 的可见消息每 5 秒批量刷新一次。返回向量必须
// 保持请求位置,同时不能因 synthetic viewer 无 channel_members 行而报 CHANNEL_PRIVATE,
// 也不能让猜测到的其它 saved_peer 消息被读取或递增。
messageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: monoInput,
ID: []int{original.Message.ID, otherMessage.Message.ID},
Increment: true,
})
if err != nil {
t.Fatalf("subscriber getMessagesViews(monoforum): %v", err)
}
if len(messageViews.Views) != 2 || len(messageViews.Chats) == 0 {
t.Fatalf("subscriber getMessagesViews = %+v, want two positional views with channel context", messageViews)
}
if got, ok := messageViews.Views[0].GetViews(); !ok || got != 1 {
t.Fatalf("subscriber own message views = %d/%v, want 1/true", got, ok)
}
if got, ok := messageViews.Views[1].GetViews(); ok || got != 0 {
t.Fatalf("subscriber cross-saved-peer views = %d/%v, want 0/false", got, ok)
}
repeatedMessageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: monoInput, ID: []int{original.Message.ID}, Increment: true,
})
if err != nil {
t.Fatalf("subscriber repeated getMessagesViews(monoforum): %v", err)
}
if got, ok := repeatedMessageViews.Views[0].GetViews(); !ok || got != 1 {
t.Fatalf("subscriber repeated message views = %d/%v, want idempotent 1/true", got, ok)
}
adminMessageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: monoInput,
ID: []int{original.Message.ID, otherMessage.Message.ID},
Increment: true,
})
if err != nil {
t.Fatalf("admin getMessagesViews(monoforum): %v", err)
}
if got, ok := adminMessageViews.Views[0].GetViews(); !ok || got != 2 {
t.Fatalf("admin first saved-peer views = %d/%v, want 2/true", got, ok)
}
if got, ok := adminMessageViews.Views[1].GetViews(); !ok || got != 1 {
t.Fatalf("admin second saved-peer views = %d/%v, want 1/true", got, ok)
}
// Android 打开 reaction 状态时会先发 getMessagesReactions。订阅者没有 channel_members
// 行,但自己的 saved_peer 消息必须正常返回,并携带 saved_peer_id 供客户端归组。
reactionState, err := r.onMessagesGetMessagesReactions(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesReactionsRequest{
Peer: monoInput,
ID: []int{original.Message.ID},
})
if err != nil {
t.Fatalf("subscriber getMessagesReactions(monoforum): %v", err)
}
reactionUpdates, ok := reactionState.(*tg.Updates)
if !ok || len(reactionUpdates.Updates) != 1 {
t.Fatalf("getMessagesReactions = %#v, want one update", reactionState)
}
reactionUpdate, ok := reactionUpdates.Updates[0].(*tg.UpdateMessageReactions)
if !ok {
t.Fatalf("getMessagesReactions update = %T, want UpdateMessageReactions", reactionUpdates.Updates[0])
}
savedPeer, ok := reactionUpdate.GetSavedPeerID()
if !ok {
t.Fatalf("getMessagesReactions update missing saved_peer_id")
}
if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("reaction saved_peer_id = %#v, want subscriber %d", savedPeer, sub.ID)
}
sendReaction := &tg.MessagesSendReactionRequest{Peer: monoInput, MsgID: original.Message.ID}
sendReaction.SetReaction([]tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}})
sentReaction, err := r.onMessagesSendReaction(WithUserID(ctx, sub.ID), sendReaction)
if err != nil {
t.Fatalf("subscriber sendReaction(monoforum): %v", err)
}
sentReactionUpdates, ok := sentReaction.(*tg.Updates)
if !ok || len(sentReactionUpdates.Updates) != 1 {
t.Fatalf("sendReaction = %#v, want one update", sentReaction)
}
sentReactionUpdate, ok := sentReactionUpdates.Updates[0].(*tg.UpdateMessageReactions)
if !ok {
t.Fatalf("sendReaction update = %T, want UpdateMessageReactions", sentReactionUpdates.Updates[0])
}
if saved, ok := sentReactionUpdate.GetSavedPeerID(); !ok {
t.Fatalf("sendReaction update missing saved_peer_id")
} else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("sendReaction saved_peer_id = %#v, want subscriber %d", saved, sub.ID)
}
if _, err := r.onMessagesSendReaction(WithUserID(ctx, sub.ID), &tg.MessagesSendReactionRequest{
Peer: monoInput, MsgID: otherMessage.Message.ID,
}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("cross-saved-peer sendReaction err = %v, want MESSAGE_ID_INVALID", err)
}
// Add Offer:源消息和目标会话都是 monoforum,自身 topic 由
// inputReplyToMonoForum(self) 选择;这不是普通 forum reply root。
addOffer := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{original.Message.ID},
RandomID: []int64{8001},
ToPeer: monoInput,
}
addOffer.SetDropAuthor(true)
addOffer.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerSelf{}})
offer := tg.SuggestedPost{}
offer.SetPrice(&tg.StarsAmount{Amount: 25})
offer.SetScheduleDate(2_000_000_000)
addOffer.SetSuggestedPost(offer)
addOfferResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), addOffer)
if err != nil {
t.Fatalf("subscriber Add Offer forwardMessages(monoforum): %v", err)
}
proposal, proposalUpdate := requireMonoforumNewChannelMessage(t, addOfferResult)
if proposal.Message != original.Message.Body {
t.Fatalf("proposal body = %q, want copied source %q", proposal.Message, original.Message.Body)
}
if proposalUpdate.PtsCount != 1 || proposalUpdate.Pts <= original.Message.Pts {
t.Fatalf("proposal pts/count = %d/%d, want one real channel event after %d", proposalUpdate.Pts, proposalUpdate.PtsCount, original.Message.Pts)
}
if _, ok := proposal.GetFwdFrom(); ok {
t.Fatalf("drop_author proposal unexpectedly has fwd_from: %#v", proposal)
}
if saved, ok := proposal.GetSavedPeerID(); !ok {
t.Fatalf("proposal missing saved_peer_id")
} else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("proposal saved_peer_id = %#v, want subscriber %d", saved, sub.ID)
}
proposalSuggested, ok := proposal.GetSuggestedPost()
if !ok {
t.Fatalf("proposal missing suggested_post")
}
if price, ok := proposalSuggested.GetPrice(); !ok {
t.Fatalf("proposal suggested_post missing price")
} else if stars, ok := price.(*tg.StarsAmount); !ok || stars.Amount != 25 {
t.Fatalf("proposal price = %#v, want 25 Stars", price)
}
// Edit Price/Edit Time:Android 同时带 reply_to、monoforum_peer_id 和冗余
// top_msg_id;服务端保留真实 reply,接受相等的冗余值但不制造 forum topic root。
editOffer := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{proposal.ID},
RandomID: []int64{8002},
ToPeer: monoInput,
}
editOffer.SetDropAuthor(true)
editReply := &tg.InputReplyToMessage{ReplyToMsgID: proposal.ID}
editReply.SetMonoforumPeerID(&tg.InputPeerSelf{})
editOffer.SetReplyTo(editReply)
editOffer.SetTopMsgID(proposal.ID)
edited := tg.SuggestedPost{}
edited.SetPrice(&tg.StarsAmount{Amount: 40})
edited.SetScheduleDate(2_000_100_000)
editOffer.SetSuggestedPost(edited)
editResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), editOffer)
if err != nil {
t.Fatalf("subscriber Edit Offer forwardMessages(monoforum): %v", err)
}
editedProposal, editedUpdate := requireMonoforumNewChannelMessage(t, editResult)
if editedUpdate.PtsCount != 1 || editedUpdate.Pts != proposalUpdate.Pts+1 {
t.Fatalf("edited proposal pts/count = %d/%d, want %d/1", editedUpdate.Pts, editedUpdate.PtsCount, proposalUpdate.Pts+1)
}
replyHeader, ok := editedProposal.ReplyTo.(*tg.MessageReplyHeader)
if !ok || replyHeader.ReplyToMsgID != proposal.ID {
t.Fatalf("edited proposal reply = %#v, want message %d", editedProposal.ReplyTo, proposal.ID)
}
if topID, ok := replyHeader.GetReplyToTopID(); ok || topID != 0 {
t.Fatalf("edited proposal acquired forum top_msg_id = %d/%v, want absent", topID, ok)
}
editedSuggested, ok := editedProposal.GetSuggestedPost()
if !ok {
t.Fatalf("edited proposal missing suggested_post")
}
if price, ok := editedSuggested.GetPrice(); !ok {
t.Fatalf("edited proposal missing price")
} else if stars, ok := price.(*tg.StarsAmount); !ok || stars.Amount != 40 {
t.Fatalf("edited proposal price = %#v, want 40 Stars", price)
}
if schedule, ok := editedSuggested.GetScheduleDate(); !ok || schedule != 2_000_100_000 {
t.Fatalf("edited proposal schedule = %d/%v, want 2000100000/true", schedule, ok)
}
beforeReplay, err := channelStore.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{
MonoforumID: monoID, SavedPeer: subPeer, Limit: 100,
})
if err != nil {
t.Fatalf("history before replay: %v", err)
}
replayResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), editOffer)
if err != nil {
t.Fatalf("exact Edit Offer replay: %v", err)
}
replayedProposal, replayedUpdate := requireMonoforumNewChannelMessage(t, replayResult)
afterReplay, err := channelStore.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{
MonoforumID: monoID, SavedPeer: subPeer, Limit: 100,
})
if err != nil {
t.Fatalf("history after replay: %v", err)
}
if replayedProposal.ID != editedProposal.ID || replayedUpdate.Pts != editedUpdate.Pts || afterReplay.Count != beforeReplay.Count {
t.Fatalf("exact replay id/pts/count = %d/%d/%d, want %d/%d/%d",
replayedProposal.ID, replayedUpdate.Pts, afterReplay.Count,
editedProposal.ID, editedUpdate.Pts, beforeReplay.Count)
}
conflict := *editOffer
conflictingSuggested := tg.SuggestedPost{}
conflictingSuggested.SetPrice(&tg.StarsAmount{Amount: 41})
conflict.SetSuggestedPost(conflictingSuggested)
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), &conflict); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_DUPLICATE") {
t.Fatalf("conflicting Edit Offer replay err = %v, want RANDOM_ID_DUPLICATE", err)
}
// 精确 id 查询必须服从 saved_peer 过滤,不能把其它订阅者的消息作为转发源。
crossSource := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{otherMessage.Message.ID},
RandomID: []int64{8003},
ToPeer: monoInput,
}
crossSource.SetDropAuthor(true)
crossSource.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerSelf{}})
crossSource.SetSuggestedPost(offer)
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), crossSource); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("cross-saved-peer forward source err = %v, want MESSAGE_ID_INVALID", err)
}
// 管理员可见全部子会话,但目标仍必须显式指定,写入同一 subscriber saved_peer。
adminOffer := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{original.Message.ID},
RandomID: []int64{8004},
ToPeer: monoInput,
}
adminOffer.SetDropAuthor(true)
adminOffer.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}})
adminOffer.SetSuggestedPost(offer)
adminResult, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), adminOffer)
if err != nil {
t.Fatalf("admin Add Offer forwardMessages(monoforum): %v", err)
}
adminProposal, _ := requireMonoforumNewChannelMessage(t, adminResult)
if saved, ok := adminProposal.GetSavedPeerID(); !ok {
t.Fatalf("admin proposal missing saved_peer_id")
} else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("admin proposal saved_peer_id = %#v, want subscriber %d", saved, sub.ID)
}
}
func requireMonoforumNewChannelMessage(t *testing.T, updatesClass tg.UpdatesClass) (*tg.Message, *tg.UpdateNewChannelMessage) {
t.Helper()
updates, ok := updatesClass.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", updatesClass)
}
for _, update := range updates.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
message, ok := newMessage.Message.(*tg.Message)
if !ok {
t.Fatalf("new channel message = %T, want *tg.Message", newMessage.Message)
}
return message, newMessage
}
t.Fatalf("updates missing UpdateNewChannelMessage: %#v", updates.Updates)
return nil, nil
}

View file

@ -0,0 +1,144 @@
package rpc
import (
"context"
cryptorand "crypto/rand"
"encoding/binary"
"errors"
"sync/atomic"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
var privateNoForwardsRandomFallback atomic.Uint64
func (r *Router) onMessagesToggleNoForwards(ctx context.Context, req *tg.MessagesToggleNoForwardsRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
channel, err := r.deps.Channels.SetNoForwards(ctx, userID, peer.ID, req.Enabled)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
input, ok := req.Peer.(*tg.InputPeerUser)
if !ok || input == nil || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID ||
r.deps.Users == nil {
return nil, peerIDInvalidErr()
}
if err := r.validateInputUser(ctx, &tg.InputUser{UserID: input.UserID, AccessHash: input.AccessHash}); err != nil {
return nil, peerIDInvalidErr()
}
target, found, err := r.deps.Users.ByID(ctx, userID, peer.ID)
if err != nil {
return nil, internalErr()
}
if !found || target.Bot || target.Support || target.Deleted {
return nil, peerIDInvalidErr()
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
}
if self.Bot || self.Support || self.Deleted {
return nil, peerIDInvalidErr()
}
svc, ok := r.deps.Messages.(PrivateNoForwardsService)
if !ok {
return nil, notImplementedErr()
}
requestMsgID, hasRequestMsgID := req.GetRequestMsgID()
if hasRequestMsgID && (requestMsgID <= 0 || requestMsgID > domain.MaxMessageBoxID) {
return nil, requestMsgExpiredErr()
}
if !hasRequestMsgID {
requestMsgID = 0
}
current, err := svc.GetPrivateNoForwards(ctx, userID, peer.ID)
if err != nil {
return nil, privateNoForwardsErr(err)
}
// Premium is required only to create a new protected state. Disabling,
// answering a request and no-op retries remain possible after expiry.
if req.Enabled && requestMsgID == 0 && !current.Enabled() && !self.PremiumActiveAt(r.clock.Now().Unix()) {
return nil, premiumAccountRequiredErr()
}
if requestMsgID != 0 || current.Enabled() != req.Enabled {
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
return nil, err
}
}
sessionID, _ := SessionIDFrom(ctx)
result, err := svc.TogglePrivateNoForwards(ctx, userID, domain.TogglePrivateNoForwardsRequest{
ActorUserID: userID,
PeerUserID: peer.ID,
Enabled: req.Enabled,
RequestMsgID: requestMsgID,
RandomID: newPrivateNoForwardsRandomID(),
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {
return nil, privateNoForwardsErr(err)
}
if result.Changed {
r.invalidateRPCProjectionForPeer(userID, peer)
r.invalidateRPCProjectionForPeer(peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
if !result.Changed || result.Send.SenderMessage.ID == 0 {
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}
return tgPrivateMessageUpdates(
result.Send.SenderEvent,
result.Send.SenderMessage,
0,
false,
r.usersForMessageUpdate(ctx, userID, result.Send.SenderMessage),
[]tg.ChatClass{},
), nil
}
func privateNoForwardsErr(err error) error {
switch {
case errors.Is(err, domain.ErrNoForwardsRequestExpired),
errors.Is(err, domain.ErrReplyMessageIDInvalid):
return requestMsgExpiredErr()
case errors.Is(err, domain.ErrMessageIDInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrChatForwardsRestricted):
return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
return randomIDDuplicateErr()
default:
return internalErr()
}
}
func newPrivateNoForwardsRandomID() int64 {
var raw [8]byte
if _, err := cryptorand.Read(raw[:]); err == nil {
if value := int64(binary.LittleEndian.Uint64(raw[:])); value != 0 {
return value
}
}
value := privateNoForwardsRandomFallback.Add(1)
return int64(value | 1<<62)
}

View file

@ -0,0 +1,167 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appdialogs "telesrv/internal/app/dialogs"
appmessages "telesrv/internal/app/messages"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestMessagesToggleNoForwardsPrivateFullFlow(t *testing.T) {
ctx := context.Background()
usersStore := memory.NewUserStore()
alice, _ := usersStore.Create(ctx, domain.User{AccessHash: 5101, Phone: "15550005101", FirstName: "Alice"})
bob, _ := usersStore.Create(ctx, domain.User{AccessHash: 5102, Phone: "15550005102", FirstName: "Bob"})
if _, err := usersStore.SetPremiumUntil(ctx, alice.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
t.Fatalf("grant alice premium: %v", err)
}
dialogsStore := memory.NewDialogStore()
messagesStore := memory.NewMessageStore(dialogsStore)
router := New(Config{}, Deps{
Users: appusers.NewService(usersStore),
Dialogs: appdialogs.NewService(dialogsStore),
Messages: appmessages.NewService(messagesStore, dialogsStore),
}, zaptest.NewLogger(t), clock.System)
enable, err := router.onMessagesToggleNoForwards(WithUserID(ctx, alice.ID), &tg.MessagesToggleNoForwardsRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, Enabled: true,
})
if err != nil {
t.Fatalf("enable private noforwards: %v", err)
}
enableMessage := noForwardsServiceMessage(t, enable)
if _, ok := enableMessage.Action.(*tg.MessageActionNoForwardsToggle); !ok {
t.Fatalf("enable action = %T", enableMessage.Action)
}
assertNoForwardsFullFlags(t, router, ctx, alice, bob, true, false)
assertNoForwardsFullFlags(t, router, ctx, bob, alice, false, true)
source, err := messagesStore.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 5199, Message: "protected source", Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send protected source: %v", err)
}
if _, err := router.onMessagesForwardMessages(WithUserID(ctx, alice.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
ID: []int{source.SenderMessage.ID},
RandomID: []int64{5200},
ToPeer: &tg.InputPeerSelf{},
}); !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") {
t.Fatalf("forward protected private chat err=%v, want CHAT_FORWARDS_RESTRICTED", err)
}
// The other party cannot steal ownership by setting enabled=true. This is a
// no-op and does not require that party to be premium.
noOp, err := router.onMessagesToggleNoForwards(WithUserID(ctx, bob.ID), &tg.MessagesToggleNoForwardsRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, Enabled: true,
})
if err != nil || len(noOp.(*tg.Updates).Updates) != 0 {
t.Fatalf("peer repeat enable = %#v err=%v, want empty no-op", noOp, err)
}
requestUpdates, err := router.onMessagesToggleNoForwards(WithUserID(ctx, bob.ID), &tg.MessagesToggleNoForwardsRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, Enabled: false,
})
if err != nil {
t.Fatalf("request sharing: %v", err)
}
requestMessage := noForwardsServiceMessage(t, requestUpdates)
requestAction, ok := requestMessage.Action.(*tg.MessageActionNoForwardsRequest)
if !ok || requestAction.Expired || !requestAction.PrevValue || requestAction.NewValue {
t.Fatalf("request action = %#v", requestMessage.Action)
}
aliceHistory, err := messagesStore.ListByUser(ctx, alice.ID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, Limit: 20,
})
if err != nil {
t.Fatal(err)
}
var aliceRequestID int
for _, msg := range aliceHistory.Messages {
if msg.Media != nil && msg.Media.ServiceAction != nil &&
msg.Media.ServiceAction.Kind == domain.MessageServiceActionNoForwardsRequest {
aliceRequestID = msg.ID
}
}
if aliceRequestID == 0 {
t.Fatal("alice request box not found")
}
answerReq := &tg.MessagesToggleNoForwardsRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Enabled: false,
}
answerReq.SetRequestMsgID(aliceRequestID)
answerUpdates, err := router.onMessagesToggleNoForwards(WithUserID(ctx, alice.ID), answerReq)
if err != nil {
t.Fatalf("accept sharing request: %v", err)
}
answerMessage := noForwardsServiceMessage(t, answerUpdates)
answerAction, ok := answerMessage.Action.(*tg.MessageActionNoForwardsToggle)
if !ok || !answerAction.PrevValue || answerAction.NewValue {
t.Fatalf("answer action = %#v", answerMessage.Action)
}
if answerMessage.ReplyTo == nil {
t.Fatal("answer service message has no reply_to")
}
assertNoForwardsFullFlags(t, router, ctx, alice, bob, false, false)
assertNoForwardsFullFlags(t, router, ctx, bob, alice, false, false)
if _, err := router.onMessagesToggleNoForwards(WithUserID(ctx, alice.ID), answerReq); !tgerr.Is(err, "REQUEST_MSG_EXPIRED") {
t.Fatalf("repeat request answer err=%v, want REQUEST_MSG_EXPIRED", err)
}
if _, err := router.onMessagesToggleNoForwards(WithUserID(ctx, bob.ID), &tg.MessagesToggleNoForwardsRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, Enabled: true,
}); !tgerr.Is(err, "PREMIUM_ACCOUNT_REQUIRED") {
t.Fatalf("non-premium fresh enable err=%v, want PREMIUM_ACCOUNT_REQUIRED", err)
}
if _, err := router.onMessagesToggleNoForwards(WithUserID(ctx, alice.ID), &tg.MessagesToggleNoForwardsRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash + 1}, Enabled: true,
}); !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("wrong access hash err=%v, want PEER_ID_INVALID", err)
}
}
func noForwardsServiceMessage(t *testing.T, updates tg.UpdatesClass) *tg.MessageService {
t.Helper()
full, ok := updates.(*tg.Updates)
if !ok || len(full.Updates) != 1 {
t.Fatalf("updates = %#v, want one updateNewMessage", updates)
}
newMessage, ok := full.Updates[0].(*tg.UpdateNewMessage)
if !ok || newMessage.Pts <= 0 || newMessage.PtsCount != 1 {
t.Fatalf("update = %#v, want updateNewMessage pts_count=1", full.Updates[0])
}
service, ok := newMessage.Message.(*tg.MessageService)
if !ok {
t.Fatalf("message = %#v, want messageService (which has no message.noforwards field)", newMessage.Message)
}
return service
}
func assertNoForwardsFullFlags(t *testing.T, router *Router, ctx context.Context, viewer, target domain.User, wantMy, wantPeer bool) {
t.Helper()
full, err := router.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{
UserID: target.ID, AccessHash: target.AccessHash,
})
if err != nil {
t.Fatalf("get full user %d->%d: %v", viewer.ID, target.ID, err)
}
if full.FullUser.GetNoforwardsMyEnabled() != wantMy ||
full.FullUser.GetNoforwardsPeerEnabled() != wantPeer {
t.Fatalf("full flags %d->%d my=%v peer=%v, want %v/%v",
viewer.ID, target.ID,
full.FullUser.GetNoforwardsMyEnabled(), full.FullUser.GetNoforwardsPeerEnabled(),
wantMy, wantPeer)
}
}

View file

@ -265,12 +265,14 @@ func (r *Router) onMessagesGetUnreadPollVotes(ctx context.Context, req *tg.Messa
if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) {
return nil, messageIDInvalidErr()
}
return &tg.MessagesMessages{
result := &tg.MessagesMessages{
Messages: []tg.MessageClass{},
Topics: []tg.ForumTopicClass{},
Chats: r.chatsForInputPeer(ctx, userID, req.Peer),
Users: []tg.UserClass{},
}, nil
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
func (r *Router) onMessagesReadPollVotes(ctx context.Context, req *tg.MessagesReadPollVotesRequest) (*tg.MessagesAffectedHistory, error) {

View file

@ -141,6 +141,7 @@ func (r *Router) onMessagesGetQuickReplyMessages(ctx context.Context, req *tg.Me
}
out := tgMessagesQuickReplyMessages(list)
out.Users = r.quickReplyUsers(ctx, userID)
r.applyPeerReadModelsToMessages(ctx, userID, out)
return out, nil
}
@ -271,7 +272,7 @@ func (r *Router) onMessagesSaveQuickReplyText(ctx context.Context, req *tg.Messa
Date: int(r.clock.Now().Unix()),
Message: req.Message,
// 快速回复模板与普通发送一致补服务端自动实体(url/@mention/#hashtag/bot command)。
Entities: domainMessageEntitiesForViewer(userID, augmentAutoEntities(req.Message, req.Entities)),
Entities: domainMessageEntitiesForViewer(userID, r.augmentAutoEntities(req.Message, req.Entities)),
})
if err != nil {
return nil, businessAutomationErr(err)
@ -313,7 +314,7 @@ func (r *Router) quickReplyUsers(ctx context.Context, userID int64) []tg.UserCla
if err != nil || self.ID == 0 {
return []tg.UserClass{}
}
return []tg.UserClass{r.tgSelfUser(self)}
return []tg.UserClass{r.tgSelfUserWithUsernames(ctx, self)}
}
func (r *Router) quickReplyMutationUpdates(ctx context.Context, userID int64, mutation domain.QuickReplyMutation, prefix []tg.UpdateClass) (*tg.Updates, error) {

View file

@ -2,10 +2,13 @@ package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"hash/fnv"
"strconv"
"crypto/md5"
"encoding/binary"
"sort"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
@ -69,33 +72,34 @@ func (r *Router) onMessagesGetSavedReactionTags(ctx context.Context, req *tg.Mes
if err != nil {
return nil, internalErr()
}
var savedPeer domain.Peer
if peer, ok := req.GetPeer(); ok && peer != nil {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
savedPeer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
if err != nil {
return nil, err
}
}
if r.deps.Messages == nil {
return savedReactionTagsEmpty(req.Hash), nil
}
if r.deps.Channels == nil {
return savedReactionTagsEmpty(req.Hash), nil
}
tags, err := r.deps.Channels.SavedReactionTags(ctx, userID, domain.MaxSavedReactionTags)
tags, err := r.deps.Messages.SavedReactionTags(ctx, userID, savedPeer, domain.MaxSavedReactionTags)
if err != nil {
return nil, channelInvalidErr(err)
return nil, messageReactionErr(err)
}
return savedReactionTagsFromDomain(tags, req.Hash), nil
return savedReactionTagsFromDomain(tags, req.Hash, savedPeer.ID == 0), nil
}
func (r *Router) onMessagesGetDefaultTagReactions(ctx context.Context, hash int64) (tg.MessagesReactionsClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return messagesReactionsEmpty(hash), nil
return messagesReactionsFromDomain(
r.reactionsWithCatalogFallback(ctx, nil, domain.MaxChannelMessageReactionsPerUser),
hash,
), nil
}
func messagesReactionsEmpty(hash int64) tg.MessagesReactionsClass {
if hash != 0 {
return &tg.MessagesReactionsNotModified{}
}
func messagesReactionsEmpty(_ int64) tg.MessagesReactionsClass {
return &tg.MessagesReactions{
Hash: 0,
Reactions: []tg.ReactionClass{},
@ -127,8 +131,15 @@ func savedReactionTagsEmpty(_ int64) tg.MessagesSavedReactionTagsClass {
}
}
func savedReactionTagsFromDomain(tags []domain.SavedReactionTag, requestHash int64) tg.MessagesSavedReactionTagsClass {
hash := savedReactionTagListHash(tags)
func savedReactionTagsFromDomain(tags []domain.SavedReactionTag, requestHash int64, includeTitles bool) tg.MessagesSavedReactionTagsClass {
tags = append([]domain.SavedReactionTag(nil), tags...)
sort.SliceStable(tags, func(i, j int) bool {
if tags[i].Count != tags[j].Count {
return tags[i].Count > tags[j].Count
}
return savedReactionTagLongID(tags[i].Reaction) > savedReactionTagLongID(tags[j].Reaction)
})
hash := savedReactionTagListHash(tags, includeTitles)
if hash != 0 && requestHash == hash {
return &tg.MessagesSavedReactionTagsNotModified{}
}
@ -142,7 +153,7 @@ func savedReactionTagsFromDomain(tags []domain.SavedReactionTag, requestHash int
Reaction: reaction,
Count: tag.Count,
}
if tag.Title != "" {
if includeTitles && tag.Title != "" {
item.SetTitle(tag.Title)
}
out = append(out, item)
@ -239,38 +250,47 @@ func messageReactionListHash(reactions []domain.MessageReaction) int64 {
if len(reactions) == 0 {
return 0
}
h := fnv.New64a()
var hash uint64
for _, reaction := range reactions {
_, _ = h.Write([]byte(reaction.Type))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(reaction.Value()))
_, _ = h.Write([]byte{0xff})
hash = telegramListHashNext(hash, savedReactionTagLongID(reaction))
}
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
return int64(hash)
}
func savedReactionTagListHash(tags []domain.SavedReactionTag) int64 {
func savedReactionTagListHash(tags []domain.SavedReactionTag, includeTitles bool) int64 {
if len(tags) == 0 {
return 0
}
h := fnv.New64a()
var hash uint64
for _, tag := range tags {
_, _ = h.Write([]byte(tag.Reaction.Type))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(tag.Reaction.Value()))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(tag.Title))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(strconv.Itoa(tag.Count)))
_, _ = h.Write([]byte{0xff})
hash = telegramListHashNext(hash, savedReactionTagLongID(tag.Reaction))
if includeTitles && tag.Title != "" {
hash = telegramListHashNext(hash, md5LongID(tag.Title))
}
hash = telegramListHashNext(hash, uint64(tag.Count))
}
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
return int64(hash)
}
func savedReactionTagLongID(reaction domain.MessageReaction) uint64 {
switch reaction.Type {
case domain.MessageReactionEmoji:
return md5LongID(strings.ReplaceAll(reaction.Emoticon, "\ufe0f", ""))
case domain.MessageReactionCustomEmoji:
return uint64(reaction.DocumentID)
default:
return 0
}
}
func md5LongID(value string) uint64 {
sum := md5.Sum([]byte(value))
return binary.BigEndian.Uint64(sum[:8])
}
func telegramListHashNext(hash, id uint64) uint64 {
hash ^= hash >> 21
hash ^= hash << 35
hash ^= hash >> 4
return hash + id
}

View file

@ -3,10 +3,12 @@ package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
"strings"
"telesrv/internal/domain"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onMessagesUpdateSavedReactionTag(ctx context.Context, req *tg.MessagesUpdateSavedReactionTagRequest) (bool, error) {
@ -18,8 +20,8 @@ func (r *Router) onMessagesUpdateSavedReactionTag(ctx context.Context, req *tg.M
if err != nil {
return false, err
}
if reaction.Type != domain.MessageReactionEmoji {
return false, reactionInvalidErr()
if !r.viewerPremium(ctx, userID) {
return false, premiumAccountRequiredErr()
}
title, ok := req.GetTitle()
if !ok {
@ -28,13 +30,13 @@ func (r *Router) onMessagesUpdateSavedReactionTag(ctx context.Context, req *tg.M
if utf8.RuneCountInString(title) > maxSavedReactionTagTitle {
return false, limitInvalidErr()
}
if r.deps.Channels != nil {
if err := r.deps.Channels.UpdateSavedReactionTag(ctx, userID, domain.SavedReactionTag{
if r.deps.Messages != nil {
if err := r.deps.Messages.UpdateSavedReactionTag(ctx, userID, domain.SavedReactionTag{
UserID: userID,
Reaction: reaction,
Title: title,
}); err != nil {
return false, channelInvalidErr(err)
return false, messageReactionErr(err)
}
}
r.pushUserUpdates(ctx, userID, &tg.Updates{
@ -166,6 +168,8 @@ func messageReactionErr(err error) error {
switch {
case errors.Is(err, domain.ErrMessageIDInvalid):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrReactionInvalid):
return reactionInvalidErr()
default:
return internalErr()
}
@ -173,6 +177,8 @@ func messageReactionErr(err error) error {
func channelReactionErr(err error) error {
switch {
case errors.Is(err, domain.ErrMessageIDInvalid):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrReactionInvalid):
return reactionInvalidErr()
case errors.Is(err, domain.ErrReactionsTooMany):

View file

@ -301,7 +301,7 @@ func (r *Router) onMessagesGetUnreadReactions(ctx context.Context, req *tg.Messa
out.Messages = append(out.Messages, item)
}
}
r.applyStoryMaxIDsToMessages(ctx, userID, out)
r.applyPeerReadModelsToMessages(ctx, userID, out)
return out, nil
}
out := &tg.MessagesMessages{
@ -310,7 +310,7 @@ func (r *Router) onMessagesGetUnreadReactions(ctx context.Context, req *tg.Messa
Chats: r.chatsForInputPeer(ctx, userID, req.Peer),
Users: []tg.UserClass{},
}
r.applyStoryMaxIDsToMessages(ctx, userID, out)
r.applyPeerReadModelsToMessages(ctx, userID, out)
return out, nil
}

Some files were not shown because too many files have changed in this diff Show more