Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -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()),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 结果。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
161
internal/rpc/account_privacy_rpc_test.go
Normal file
161
internal/rpc/account_privacy_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
272
internal/rpc/account_rating_projection_test.go
Normal file
272
internal/rpc/account_rating_projection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
108
internal/rpc/account_reports.go
Normal file
108
internal/rpc/account_reports.go
Normal 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
|
||||
}
|
||||
}
|
||||
71
internal/rpc/account_reports_rpc_test.go
Normal file
71
internal/rpc/account_reports_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
831
internal/rpc/bot_verification_flags_test.go
Normal file
831
internal/rpc/bot_verification_flags_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
107
internal/rpc/bot_verification_notify.go
Normal file
107
internal/rpc/bot_verification_notify.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// NotifyPeerBotVerification is the protocol-edge hook invoked after a third-party
|
||||
// verification mark has already committed -- from bots.setCustomVerification, or
|
||||
// from an operator action on the same rows. It makes the new
|
||||
// user#b1b8cc83 bot_verification_icon:flags2.14 /
|
||||
// channel#d49f34c6 bot_verification_icon:flags2.13 (and the matching
|
||||
// userFull/channelFull payload) observable without waiting for a cache TTL:
|
||||
//
|
||||
// - the cached peer projections for the target are dropped, so the next
|
||||
// users.getFullUser / channels.getFullChannel rebuilds from the committed row;
|
||||
// - online clients that already know the peer are pushed the ordinary, non-PTS
|
||||
// refresh update (updateUser / updateChannel) together with the re-projected
|
||||
// peer object, which is what makes the badge appear live in an official client.
|
||||
//
|
||||
// It is deliberately the same mechanism as NotifyPeerVerified, which is the same
|
||||
// mechanism the scam/fake moderation flags use: the third-party mark is one more
|
||||
// fact on the peer's base record, and inventing a verification-specific update
|
||||
// would only add a second path that can drift. Nothing new is added to TL.
|
||||
//
|
||||
// The official verified flag is untouched here. That badge is granted by the
|
||||
// operator alone (see verification_notify.go) and the two mechanisms never read or
|
||||
// write each other's state.
|
||||
//
|
||||
// Offline sessions are not pushed to and do not need to be: the peer's base read
|
||||
// model version is bumped by the users/channels triggers, so
|
||||
// updates.getDifference and any later getUsers / getFullUser answer already carries
|
||||
// the new mark.
|
||||
//
|
||||
// A push failure never invalidates the committed change, so callers log and swallow
|
||||
// the returned error; this method therefore reports problems instead of panicking,
|
||||
// and is safe on a nil receiver.
|
||||
func (r *Router) NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if peer.ID <= 0 {
|
||||
return fmt.Errorf("notify peer bot verification: invalid peer id %d", peer.ID)
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
return r.notifyUserBotVerification(ctx, peer.ID)
|
||||
case domain.PeerTypeChannel:
|
||||
return r.notifyChannelBotVerification(ctx, peer.ID)
|
||||
default:
|
||||
return fmt.Errorf("notify peer bot verification: unsupported peer type %q for peer %d", peer.Type, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyUserBotVerification covers ordinary accounts and bots alike: a marked bot is
|
||||
// a user#b1b8cc83 with bot_verification_icon:flags2.14, so it takes the same
|
||||
// audience-wide updateUser fan-out the moderation flags use (the peer itself plus
|
||||
// every online account that already sees it).
|
||||
func (r *Router) notifyUserBotVerification(ctx context.Context, userID int64) error {
|
||||
// Invalidate first and unconditionally: a committed mark whose projection still
|
||||
// says "unmarked" would keep serving the stale badge state even if the push below
|
||||
// cannot run.
|
||||
r.invalidateRPCProjectionForUser(userID)
|
||||
if r.deps.Users == nil {
|
||||
return nil
|
||||
}
|
||||
user, found, err := r.verificationUser(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify peer bot verification: load user %d: %w", userID, err)
|
||||
}
|
||||
if !found || user.ID == 0 {
|
||||
return fmt.Errorf("notify peer bot verification: user %d not found", userID)
|
||||
}
|
||||
// NotifyUserModerationFlagsChanged re-projects the peer per recipient, so the
|
||||
// snapshot handed in only carries identity; the pushed tg.User is always built
|
||||
// from a fresh read and therefore picks the icon up from the batch overlay.
|
||||
return r.NotifyUserModerationFlagsChanged(ctx, user)
|
||||
}
|
||||
|
||||
// notifyChannelBotVerification reuses the channel state-mutation path, which
|
||||
// invalidates the channel projections (plus a linked monoforum's) and pushes
|
||||
// updateChannel with the refreshed chat object to the channel's members.
|
||||
func (r *Router) notifyChannelBotVerification(ctx context.Context, channelID int64) error {
|
||||
r.invalidateRPCProjectionForChannel(channelID)
|
||||
// The bot list cached for channelFull carries botInfo.verifier_settings, so a
|
||||
// verifier's own status change has to drop it too; the channel projection cache
|
||||
// is cleared by the same call.
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(channelID)
|
||||
if r.deps.Channels == nil {
|
||||
return nil
|
||||
}
|
||||
directory, ok := r.deps.Channels.(verificationChannelDirectory)
|
||||
if !ok {
|
||||
return fmt.Errorf("notify peer bot verification: channel service does not expose GetChannelByID")
|
||||
}
|
||||
channel, err := directory.GetChannelByID(ctx, channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify peer bot verification: load channel %d: %w", channelID, err)
|
||||
}
|
||||
if channel.ID == 0 {
|
||||
return fmt.Errorf("notify peer bot verification: channel %d not found", channelID)
|
||||
}
|
||||
// Same hook the admin panel uses for any other channel base fact.
|
||||
return r.NotifyChannelChanged(ctx, channel)
|
||||
}
|
||||
335
internal/rpc/bot_verification_notify_test.go
Normal file
335
internal/rpc/bot_verification_notify_test.go
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestNotifyPeerBotVerificationUserInvalidatesAndPushesUpdateUser is the bot/user
|
||||
// half: a newly marked peer must reach every online account that already sees it,
|
||||
// through the same non-PTS updateUser shape the scam/fake flags use, and the pushed
|
||||
// tg.User must already carry bot_verification_icon:flags2.14.
|
||||
func TestNotifyPeerBotVerificationUserInvalidatesAndPushesUpdateUser(t *testing.T) {
|
||||
const (
|
||||
shopID = int64(2200000022)
|
||||
onlineViewerID = int64(1001)
|
||||
offlineViewerID = int64(3003)
|
||||
icon = int64(9900001)
|
||||
)
|
||||
users := &verifiedNotifyUsers{
|
||||
user: domain.User{ID: shopID, FirstName: "Shop", AccessHash: 22},
|
||||
found: true,
|
||||
audience: []int64{shopID, onlineViewerID, offlineViewerID},
|
||||
}
|
||||
verify := newFakeBotVerifications()
|
||||
verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: shopID}] = domain.CustomVerification{
|
||||
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: shopID},
|
||||
IconDocumentID: icon, Description: "Verified by Acme Trust",
|
||||
}
|
||||
sessions := &captureSessions{onlineUserIDs: []int64{shopID, onlineViewerID}}
|
||||
r := New(Config{}, Deps{Users: users, Sessions: sessions, BotVerifications: verify}, zap.NewNop(), clock.System)
|
||||
seedUserFullProjection(t, r, onlineViewerID, shopID)
|
||||
seedUserFullProjection(t, r, shopID, shopID)
|
||||
|
||||
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeUser, ID: shopID,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify peer bot verification: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := r.userFullProjectionCache.Lookup(onlineViewerID, shopID); ok {
|
||||
t.Fatal("viewer userFull projection survived the mark change")
|
||||
}
|
||||
if _, ok := r.userFullProjectionCache.Lookup(shopID, shopID); ok {
|
||||
t.Fatal("target userFull projection survived the mark change")
|
||||
}
|
||||
if users.adminCalls != 1 {
|
||||
t.Fatalf("authoritative account reads = %d, want 1", users.adminCalls)
|
||||
}
|
||||
// One read for the whole audience: the mark is a peer-wide fact, so the
|
||||
// per-recipient push builder must not query it again for every recipient.
|
||||
if verify.peerCalls != 1 || verify.batchCalls != 0 {
|
||||
t.Fatalf("verification reads = peer %d / batch %d, want peer 1 / batch 0",
|
||||
verify.peerCalls, verify.batchCalls)
|
||||
}
|
||||
|
||||
// Offline audience members are skipped; they converge through the bumped peer
|
||||
// read model on their next getDifference / getUsers.
|
||||
pushed := sessions.pushedUserIDs()
|
||||
if len(pushed) != 2 || pushed[0] != shopID || pushed[1] != onlineViewerID {
|
||||
t.Fatalf("pushed user ids = %v", pushed)
|
||||
}
|
||||
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 {
|
||||
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
refresh, ok := updates.Updates[0].(*tg.UpdateUser)
|
||||
if !ok || refresh.UserID != shopID {
|
||||
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
|
||||
}
|
||||
if len(updates.Users) != 1 {
|
||||
t.Fatalf("users = %+v", updates.Users)
|
||||
}
|
||||
pushedUser := &tg.User{}
|
||||
tlRoundTrip(t, updates.Users[0].(*tg.User), pushedUser)
|
||||
got, ok := pushedUser.GetBotVerificationIcon()
|
||||
if !ok || got != icon {
|
||||
t.Fatalf("pushed user bot_verification_icon = %d ok=%v, want %d on flags2.14", got, ok, icon)
|
||||
}
|
||||
if !pushedUser.Flags2.Has(14) {
|
||||
t.Fatalf("pushed user flags2 = %032b, want bit 14", uint32(pushedUser.Flags2))
|
||||
}
|
||||
// The official checkmark is a separate mechanism and must not be implied.
|
||||
if pushedUser.Verified || pushedUser.Scam || pushedUser.Fake {
|
||||
t.Fatalf("mark push leaked moderation flags: %+v", pushedUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerBotVerificationChannelInvalidatesAndPushesUpdateChannel is the
|
||||
// channel half: the mark rides the existing channel state-mutation fan-out, and the
|
||||
// pushed tg.Channel carries bot_verification_icon:flags2.13.
|
||||
func TestNotifyPeerBotVerificationChannelInvalidatesAndPushesUpdateChannel(t *testing.T) {
|
||||
const (
|
||||
channelID = int64(4404)
|
||||
ownerID = int64(3003)
|
||||
memberID = int64(3004)
|
||||
icon = int64(9900002)
|
||||
)
|
||||
channels := &verifiedNotifyChannels{channel: domain.Channel{
|
||||
ID: channelID, AccessHash: 44, CreatorUserID: ownerID,
|
||||
Title: "Partner", Username: "partner", Broadcast: true,
|
||||
}}
|
||||
verify := newFakeBotVerifications()
|
||||
verify.marks[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = domain.CustomVerification{
|
||||
VerifierBotID: 777000123, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
IconDocumentID: icon, Description: "Verified by Acme Trust",
|
||||
}
|
||||
sessions := &captureSessions{
|
||||
onlineUserIDs: []int64{ownerID, memberID},
|
||||
channelMembers: map[int64][]int64{channelID: {ownerID, memberID}},
|
||||
}
|
||||
r := New(Config{}, Deps{Channels: channels, Sessions: sessions, BotVerifications: verify}, zap.NewNop(), clock.System)
|
||||
seedChannelFullProjection(t, r, ownerID, channelID)
|
||||
seedChannelFullProjection(t, r, memberID, channelID)
|
||||
|
||||
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeChannel, ID: channelID,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify peer bot verification: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(ownerID, channelID); ok {
|
||||
t.Fatal("owner channelFull projection survived the mark change")
|
||||
}
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(memberID, channelID); ok {
|
||||
t.Fatal("member channelFull projection survived the mark change")
|
||||
}
|
||||
if channels.calls != 1 {
|
||||
t.Fatalf("base channel row reads = %d, want 1", channels.calls)
|
||||
}
|
||||
// Same contract on the channel fan-out: one read for every recipient plus the
|
||||
// returned updates.
|
||||
if verify.peerCalls != 1 || verify.batchCalls != 0 {
|
||||
t.Fatalf("verification reads = peer %d / batch %d, want peer 1 / batch 0",
|
||||
verify.peerCalls, verify.batchCalls)
|
||||
}
|
||||
|
||||
if pushed := sessions.pushedUserIDs(); len(pushed) != 2 {
|
||||
t.Fatalf("pushed user ids = %v", pushed)
|
||||
}
|
||||
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 {
|
||||
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
refresh, ok := updates.Updates[0].(*tg.UpdateChannel)
|
||||
if !ok || refresh.ChannelID != channelID {
|
||||
t.Fatalf("refresh = %T %+v", updates.Updates[0], updates.Updates[0])
|
||||
}
|
||||
if len(updates.Chats) != 1 {
|
||||
t.Fatalf("chats = %+v", updates.Chats)
|
||||
}
|
||||
pushedChannel := &tg.Channel{}
|
||||
tlRoundTrip(t, updates.Chats[0].(*tg.Channel), pushedChannel)
|
||||
got, ok := pushedChannel.GetBotVerificationIcon()
|
||||
if !ok || got != icon {
|
||||
t.Fatalf("pushed channel bot_verification_icon = %d ok=%v, want %d on flags2.13", got, ok, icon)
|
||||
}
|
||||
if !pushedChannel.Flags2.Has(13) {
|
||||
t.Fatalf("pushed channel flags2 = %032b, want bit 13", uint32(pushedChannel.Flags2))
|
||||
}
|
||||
if pushedChannel.Verified || pushedChannel.Scam || pushedChannel.Fake {
|
||||
t.Fatalf("mark push leaked moderation flags: %+v", pushedChannel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerBotVerificationDropsChannelBotInfoCache guards the one cache the
|
||||
// user path does not have: channelFull.bot_info bakes botInfo.verifier_settings, so a
|
||||
// verifier's own status change has to drop it or the profile keeps advertising the
|
||||
// old state for the cache TTL.
|
||||
func TestNotifyPeerBotVerificationDropsChannelBotInfoCache(t *testing.T) {
|
||||
const channelID = int64(4405)
|
||||
channels := &verifiedNotifyChannels{channel: domain.Channel{
|
||||
ID: channelID, AccessHash: 45, CreatorUserID: 3003, Title: "Partner", Broadcast: true,
|
||||
}}
|
||||
r := New(Config{}, Deps{Channels: channels, Sessions: &captureSessions{}}, zap.NewNop(), clock.System)
|
||||
epoch := r.channelFullBotCache.LoadEpoch()
|
||||
r.channelFullBotCache.StoreIfEpoch(3003, channelID, channelFullBotInfoResult{
|
||||
userIDs: []int64{777000123},
|
||||
botInfos: []tg.BotInfo{{}},
|
||||
}, epoch)
|
||||
if _, ok := r.channelFullBotCache.Lookup(3003, channelID); !ok {
|
||||
t.Fatal("seed channelFull bot info cache")
|
||||
}
|
||||
|
||||
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeChannel, ID: channelID,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify peer bot verification: %v", err)
|
||||
}
|
||||
if _, ok := r.channelFullBotCache.Lookup(3003, channelID); ok {
|
||||
t.Fatal("channelFull bot info cache survived the mark change")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerBotVerificationNilRouterIsSafe pins the nil-receiver contract: the
|
||||
// hook runs after the change already committed, so it may never panic.
|
||||
func TestNotifyPeerBotVerificationNilRouterIsSafe(t *testing.T) {
|
||||
var r *Router
|
||||
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeUser, ID: 1001,
|
||||
}); err != nil {
|
||||
t.Fatalf("nil router notify = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerBotVerificationWithoutServicesStillInvalidates covers degraded
|
||||
// wiring: with no user/channel service the hook cannot push, but the stale
|
||||
// projection must still be dropped and no error reported.
|
||||
func TestNotifyPeerBotVerificationWithoutServicesStillInvalidates(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zap.NewNop(), clock.System)
|
||||
seedUserFullProjection(t, r, 1001, 2002)
|
||||
seedChannelFullProjection(t, r, 1001, 4004)
|
||||
|
||||
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeUser, ID: 2002,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify user without users service = %v", err)
|
||||
}
|
||||
if err := r.NotifyPeerBotVerification(context.Background(), domain.Peer{
|
||||
Type: domain.PeerTypeChannel, ID: 4004,
|
||||
}); err != nil {
|
||||
t.Fatalf("notify channel without channels service = %v", err)
|
||||
}
|
||||
if _, ok := r.userFullProjectionCache.Lookup(1001, 2002); ok {
|
||||
t.Fatal("userFull projection survived without a users service")
|
||||
}
|
||||
if _, ok := r.channelFullProjectionCache.Lookup(1001, 4004); ok {
|
||||
t.Fatal("channelFull projection survived without a channels service")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerBotVerificationRejectsUnknownPeers pins the "explain, never panic"
|
||||
// contract for every peer the hook cannot act on.
|
||||
func TestNotifyPeerBotVerificationRejectsUnknownPeers(t *testing.T) {
|
||||
users := &verifiedNotifyUsers{}
|
||||
channels := &verifiedNotifyChannels{}
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{Users: users, Channels: channels, Sessions: sessions}, zap.NewNop(), clock.System)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
peer domain.Peer
|
||||
want string
|
||||
}{
|
||||
{"zero id", domain.Peer{Type: domain.PeerTypeUser}, "invalid peer id"},
|
||||
{"negative id", domain.Peer{Type: domain.PeerTypeChannel, ID: -1}, "invalid peer id"},
|
||||
{"community peer", domain.Peer{Type: domain.PeerTypeCommunity, ID: 5005}, "unsupported peer type"},
|
||||
{"empty type", domain.Peer{ID: 5005}, "unsupported peer type"},
|
||||
{"missing user", domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, "user 2002 not found"},
|
||||
{"missing channel", domain.Peer{Type: domain.PeerTypeChannel, ID: 4004}, "channel 4004 not found"},
|
||||
} {
|
||||
err := r.NotifyPeerBotVerification(ctx, tc.peer)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s: err = %v, want mention of %q", tc.name, err, tc.want)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "notify peer bot verification") {
|
||||
t.Fatalf("%s: err = %v, want the bot-verification hook named", tc.name, err)
|
||||
}
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); len(pushed) != 0 {
|
||||
t.Fatalf("unresolved peers pushed updates to %v", pushed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyPeerBotVerificationReportsLookupFailures keeps a directory error distinct
|
||||
// from "peer not found", and keeps a channels adapter without the base-row reader
|
||||
// from failing silently.
|
||||
func TestNotifyPeerBotVerificationReportsLookupFailures(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
loadErr := errors.New("boom")
|
||||
|
||||
failing := New(Config{}, Deps{
|
||||
Channels: &verifiedNotifyChannels{err: loadErr},
|
||||
Sessions: &captureSessions{},
|
||||
}, zap.NewNop(), clock.System)
|
||||
if err := failing.NotifyPeerBotVerification(ctx, domain.Peer{
|
||||
Type: domain.PeerTypeChannel, ID: 4004,
|
||||
}); !errors.Is(err, loadErr) {
|
||||
t.Fatalf("channel load error = %v", err)
|
||||
}
|
||||
|
||||
unwired := New(Config{}, Deps{
|
||||
Channels: channelsWithoutDirectory{},
|
||||
Sessions: &captureSessions{},
|
||||
}, zap.NewNop(), clock.System)
|
||||
err := unwired.NotifyPeerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: 4004})
|
||||
if err == nil || !strings.Contains(err.Error(), "GetChannelByID") {
|
||||
t.Fatalf("missing channel directory error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCustomVerificationNotifiesAudience closes the loop: a successful
|
||||
// bots.setCustomVerification must itself drop the projections and push the refresh,
|
||||
// so the badge appears in a running client without a second RPC.
|
||||
func TestSetCustomVerificationNotifiesAudience(t *testing.T) {
|
||||
fake := newFakeBotVerifications()
|
||||
f := newBotVerificationFixture(t, fake)
|
||||
// Production wiring: the service holds the router as its PeerNotifier, so the
|
||||
// push happens once, in the service, for every driver of a mark change.
|
||||
fake.notifier = f.router
|
||||
f.enableVerifier(f.bot.ID, 9900003, true)
|
||||
sessions := &captureSessions{onlineUserIDs: []int64{f.owner.ID, f.target.ID}}
|
||||
f.router.deps.Sessions = sessions
|
||||
seedUserFullProjection(t, f.router, f.owner.ID, f.target.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(WithUserID(context.Background(), f.owner.ID),
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("grant = %v,%v, want true,nil", ok, err)
|
||||
}
|
||||
if _, cached := f.router.userFullProjectionCache.Lookup(f.owner.ID, f.target.ID); cached {
|
||||
t.Fatal("userFull projection survived the grant")
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); len(pushed) == 0 {
|
||||
t.Fatal("grant pushed no refresh update")
|
||||
}
|
||||
updates, isUpdates := sessions.lastUserPush().(*tg.Updates)
|
||||
if !isUpdates || len(updates.Users) == 0 {
|
||||
t.Fatalf("push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
pushedUser := &tg.User{}
|
||||
tlRoundTrip(t, updates.Users[0].(*tg.User), pushedUser)
|
||||
if icon, set := pushedUser.GetBotVerificationIcon(); !set || icon != 9900003 {
|
||||
t.Fatalf("pushed user icon = %d set=%v, want 9900003 on flags2.14", icon, set)
|
||||
}
|
||||
}
|
||||
314
internal/rpc/bot_verification_projection.go
Normal file
314
internal/rpc/bot_verification_projection.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification on the protocol edge
|
||||
// (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// Layer 228 spreads one fact -- "verifier bot B marked peer P with icon I and
|
||||
// description D" -- over six unrelated constructors, and an official client only
|
||||
// renders the badge when the exact bit is set:
|
||||
//
|
||||
// user#b1b8cc83 bot_verification_icon:flags2.14?long
|
||||
// channel#d49f34c6 bot_verification_icon:flags2.13?long
|
||||
// userFull#6cbe645 bot_verification:flags2.12?BotVerification
|
||||
// channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
|
||||
// chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
|
||||
// botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
|
||||
//
|
||||
// Every projection here goes through the generated Set* helpers rather than a raw
|
||||
// field assignment, because the flags word is what the client reads: a struct field
|
||||
// set without its bit encodes as an absent field and the badge silently disappears.
|
||||
//
|
||||
// Deps.BotVerifications is the single source. Nothing in this file recomputes the
|
||||
// mark, and nothing derives it from the official verified flag (user flags.17 /
|
||||
// channel flags.7) -- that is a separate operator-granted mechanism, and mixing the
|
||||
// two would let a third-party verifier mint platform checkmarks.
|
||||
|
||||
// tgBotVerification projects domain.BotVerification onto botVerification#f93cd45c.
|
||||
//
|
||||
// The bool reports whether the payload is renderable at all: the icon is a custom
|
||||
// emoji document id resolved through messages.getCustomEmojiDocuments, so a zero
|
||||
// icon (or an unattributed mark) would encode a badge the client draws as nothing.
|
||||
// Such a mark is omitted instead of shipped half-formed.
|
||||
func tgBotVerification(in domain.BotVerification) (tg.BotVerification, bool) {
|
||||
if in.BotID <= 0 || in.Icon <= 0 {
|
||||
return tg.BotVerification{}, false
|
||||
}
|
||||
return tg.BotVerification{
|
||||
BotID: in.BotID,
|
||||
Icon: in.Icon,
|
||||
Description: in.Description,
|
||||
}, true
|
||||
}
|
||||
|
||||
// tgBotVerifierSettings projects domain.BotVerifierSettings onto
|
||||
// botVerifierSettings#b0cd6617.
|
||||
//
|
||||
// custom_description:flags.0 carries the operator default description (the text the
|
||||
// verifier applies when it supplies none per peer);
|
||||
// can_modify_custom_description:flags.1 is the permission that lets the verifier
|
||||
// override it. A configuration that does not validate is omitted entirely rather
|
||||
// than encoded with an empty company, which clients render as a blank badge sheet.
|
||||
func tgBotVerifierSettings(in domain.BotVerifierSettings) (tg.BotVerifierSettings, bool) {
|
||||
if err := in.Validate(); err != nil {
|
||||
return tg.BotVerifierSettings{}, false
|
||||
}
|
||||
out := tg.BotVerifierSettings{
|
||||
Icon: in.IconDocumentID,
|
||||
Company: strings.TrimSpace(in.CompanyName),
|
||||
}
|
||||
if in.CanModifyCustomDescription {
|
||||
out.SetCanModifyCustomDescription(true)
|
||||
}
|
||||
if desc := strings.TrimSpace(in.DefaultDescription); desc != "" {
|
||||
out.SetCustomDescription(desc)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// applyBotVerificationIconsToPeerObjects overlays user#b1b8cc83
|
||||
// bot_verification_icon:flags2.14 and channel#d49f34c6
|
||||
// bot_verification_icon:flags2.13 onto already-projected peer objects.
|
||||
//
|
||||
// It mirrors applyUsernamesToPeerObjects exactly, including why it is an overlay:
|
||||
// tgUser/tgChannel run inside per-id loops (users.getUsers, dialog lists), so a
|
||||
// per-object read there would be the N+1 this pass exists to avoid. Running at the
|
||||
// response boundary keeps every one of the ~90 plain projection call sites
|
||||
// untouched and still reaches them all.
|
||||
//
|
||||
// A nil service or any read error is a silent no-op: the encoded peer then stays
|
||||
// byte-identical to the pre-feature shape.
|
||||
func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, users []tg.UserClass, chats []tg.ChatClass) {
|
||||
if r.deps.BotVerifications == nil || len(users)+len(chats) == 0 {
|
||||
return
|
||||
}
|
||||
peers := make([]domain.Peer, 0, len(users)+len(chats))
|
||||
seen := make(map[domain.Peer]struct{}, len(users)+len(chats))
|
||||
addPeer := func(peer domain.Peer) {
|
||||
if peer.ID == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
return
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
peers = append(peers, peer)
|
||||
}
|
||||
for _, item := range users {
|
||||
if u, ok := item.(*tg.User); ok && u != nil {
|
||||
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
|
||||
}
|
||||
}
|
||||
for _, item := range chats {
|
||||
if ch, ok := item.(*tg.Channel); ok && ch != nil {
|
||||
addPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID})
|
||||
}
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
byPeer := r.botVerificationMap(ctx, peers)
|
||||
if len(byPeer) == 0 {
|
||||
return
|
||||
}
|
||||
for _, item := range users {
|
||||
u, ok := item.(*tg.User)
|
||||
if !ok || u == nil {
|
||||
continue
|
||||
}
|
||||
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
|
||||
if !ok || mark.IconDocumentID <= 0 {
|
||||
continue
|
||||
}
|
||||
u.SetBotVerificationIcon(mark.IconDocumentID)
|
||||
}
|
||||
for _, item := range chats {
|
||||
ch, ok := item.(*tg.Channel)
|
||||
if !ok || ch == nil {
|
||||
continue
|
||||
}
|
||||
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
|
||||
if !ok || mark.IconDocumentID <= 0 {
|
||||
continue
|
||||
}
|
||||
ch.SetBotVerificationIcon(mark.IconDocumentID)
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationMap loads the marks for the given peers. One peer goes through
|
||||
// PeerVerification so a single-object projection does not pay for a batch round
|
||||
// trip; anything larger goes through PeerVerificationBatch, so a list response
|
||||
// costs one query regardless of length. Any error yields an empty map, which every
|
||||
// caller treats as "no badge".
|
||||
func (r *Router) botVerificationMap(ctx context.Context, peers []domain.Peer) map[domain.Peer]domain.CustomVerification {
|
||||
if r.deps.BotVerifications == nil || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(peers) == 1 {
|
||||
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peers[0])
|
||||
if err != nil || mark.IconDocumentID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return map[domain.Peer]domain.CustomVerification{peers[0]: mark}
|
||||
}
|
||||
byPeer, err := r.deps.BotVerifications.PeerVerificationBatch(ctx, peers)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return byPeer
|
||||
}
|
||||
|
||||
// peerBotVerificationIcon resolves just the icon for one peer, for the update
|
||||
// fan-outs. They build one peer object per recipient from the same peer-wide fact,
|
||||
// so they resolve it once with this and then stamp it with the helpers below rather
|
||||
// than reading inside their per-recipient builder. Zero means "no mark", which
|
||||
// leaves the flag unset.
|
||||
func (r *Router) peerBotVerificationIcon(ctx context.Context, peer domain.Peer) int64 {
|
||||
if r.deps.BotVerifications == nil || peer.ID <= 0 {
|
||||
return 0
|
||||
}
|
||||
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peer)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return mark.IconDocumentID
|
||||
}
|
||||
|
||||
// applyBotVerificationIconToUsers stamps an already-resolved icon onto the matching
|
||||
// user object of one push (user#b1b8cc83 bot_verification_icon:flags2.14).
|
||||
func applyBotVerificationIconToUsers(users []tg.UserClass, userID, icon int64) {
|
||||
if icon <= 0 || userID <= 0 {
|
||||
return
|
||||
}
|
||||
for _, item := range users {
|
||||
if u, ok := item.(*tg.User); ok && u != nil && u.ID == userID {
|
||||
u.SetBotVerificationIcon(icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyBotVerificationIconToChannelChats is the channel counterpart
|
||||
// (channel#d49f34c6 bot_verification_icon:flags2.13).
|
||||
func applyBotVerificationIconToChannelChats(chats []tg.ChatClass, channelID, icon int64) {
|
||||
if icon <= 0 || channelID <= 0 {
|
||||
return
|
||||
}
|
||||
for _, item := range chats {
|
||||
if ch, ok := item.(*tg.Channel); ok && ch != nil && ch.ID == channelID {
|
||||
ch.SetBotVerificationIcon(icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// peerBotVerification resolves the single-peer projection payload.
|
||||
func (r *Router) peerBotVerification(ctx context.Context, peer domain.Peer) (tg.BotVerification, bool) {
|
||||
if r.deps.BotVerifications == nil || peer.ID <= 0 {
|
||||
return tg.BotVerification{}, false
|
||||
}
|
||||
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peer)
|
||||
if err != nil {
|
||||
// Includes domain.ErrCustomVerificationNotFound: an unmarked peer simply has
|
||||
// no badge to show.
|
||||
return tg.BotVerification{}, false
|
||||
}
|
||||
return tgBotVerification(mark.Projection())
|
||||
}
|
||||
|
||||
// applyBotVerificationToUserFull sets userFull#6cbe645
|
||||
// bot_verification:flags2.12.
|
||||
//
|
||||
// It runs as a post-cache overlay on both users.getFullUser paths (cache hit and
|
||||
// fresh build) and clears the bit first, so a revoked mark disappears on the next
|
||||
// response instead of surviving inside the per-(viewer,target) projection cache
|
||||
// TTL. The payload is viewer-independent by construction: a badge granted by a
|
||||
// verifier is a property of the peer, not of who is looking.
|
||||
func (r *Router) applyBotVerificationToUserFull(ctx context.Context, userID int64, full *tg.UserFull) {
|
||||
if r.deps.BotVerifications == nil || full == nil || userID <= 0 {
|
||||
return
|
||||
}
|
||||
full.Flags2.Unset(12)
|
||||
full.BotVerification = tg.BotVerification{}
|
||||
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}); ok {
|
||||
full.SetBotVerification(value)
|
||||
}
|
||||
}
|
||||
|
||||
// applyBotVerificationToChannelFull sets channelFull#a04e8d3a
|
||||
// bot_verification:flags2.17, on the same post-cache overlay contract as
|
||||
// applyBotVerificationToUserFull.
|
||||
func (r *Router) applyBotVerificationToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
|
||||
if r.deps.BotVerifications == nil || full == nil || channelID <= 0 {
|
||||
return
|
||||
}
|
||||
full.Flags2.Unset(17)
|
||||
full.BotVerification = tg.BotVerification{}
|
||||
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}); ok {
|
||||
full.SetBotVerification(value)
|
||||
}
|
||||
}
|
||||
|
||||
// applyBotVerificationToChatInvite sets chatInvite#5c9d3702
|
||||
// bot_verification:flags.13.
|
||||
//
|
||||
// A non-member sees only this preview, so the badge has to be visible here for the
|
||||
// same reason verified/scam/fake are (applyChatInviteModerationFlags): a mark that
|
||||
// appears only after joining is exactly backwards.
|
||||
func (r *Router) applyBotVerificationToChatInvite(ctx context.Context, invite *tg.ChatInvite, channelID int64) {
|
||||
if r.deps.BotVerifications == nil || invite == nil || channelID <= 0 {
|
||||
return
|
||||
}
|
||||
if value, ok := r.peerBotVerification(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}); ok {
|
||||
invite.SetBotVerification(value)
|
||||
}
|
||||
}
|
||||
|
||||
// applyVerifierSettingsToBotInfo sets botInfo#4d8a0299
|
||||
// verifier_settings:flags.9 -- the block a client shows inside a verifier bot's
|
||||
// own profile, and what tells it the bot may verify others.
|
||||
//
|
||||
// The operator kill switch is honoured here: a disabled verifier keeps its row and
|
||||
// the marks it already granted, but stops advertising itself as a verifier, so the
|
||||
// client stops offering the verification UI.
|
||||
func applyVerifierSettingsToBotInfo(info *tg.BotInfo, botUserID int64, settings domain.BotVerifierSettings) {
|
||||
if info == nil || botUserID <= 0 || !settings.Enabled || settings.BotID != botUserID {
|
||||
return
|
||||
}
|
||||
if value, ok := tgBotVerifierSettings(settings); ok {
|
||||
info.SetVerifierSettings(value)
|
||||
}
|
||||
}
|
||||
|
||||
// applyVerifierSettingsToOneBotInfo is the single-bot path (userFull.bot_info).
|
||||
func (r *Router) applyVerifierSettingsToOneBotInfo(ctx context.Context, info *tg.BotInfo, botUserID int64) {
|
||||
if r.deps.BotVerifications == nil || info == nil || botUserID <= 0 {
|
||||
return
|
||||
}
|
||||
settings, err := r.deps.BotVerifications.VerifierSettings(ctx, botUserID)
|
||||
if err != nil {
|
||||
// Includes domain.ErrVerifierNotFound: an ordinary bot is not a verifier.
|
||||
return
|
||||
}
|
||||
applyVerifierSettingsToBotInfo(info, botUserID, settings)
|
||||
}
|
||||
|
||||
// verifierSettingsBatch resolves verifier status for several bots at once, next to
|
||||
// the profile batch resolver tgBotInfos already uses, so channelFull.bot_info costs
|
||||
// one settings query for the whole bot list.
|
||||
func (r *Router) verifierSettingsBatch(ctx context.Context, botUserIDs []int64) map[int64]domain.BotVerifierSettings {
|
||||
if r.deps.BotVerifications == nil || len(botUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
settings, err := r.deps.BotVerifications.VerifierSettingsBatch(ctx, botUserIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return settings
|
||||
}
|
||||
537
internal/rpc/bot_verification_rpc_test.go
Normal file
537
internal/rpc/bot_verification_rpc_test.go
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
botsapp "telesrv/internal/app/bots"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// fakeBotVerifications is an in-memory BotVerificationService. It enforces the same
|
||||
// domain rules the real service must (verifier status, the operator kill switch,
|
||||
// domain.BotVerifierSettings.DescriptionFor, the per-verifier bound and
|
||||
// idempotent mutations), so the RPC tests exercise the real error mapping
|
||||
// instead of hand-rolled sentinels.
|
||||
type fakeBotVerifications struct {
|
||||
marks map[domain.Peer]domain.CustomVerification
|
||||
settings map[int64]domain.BotVerifierSettings
|
||||
// limit, when positive, bounds how many peers one verifier may mark.
|
||||
limit int
|
||||
// err, when set, fails every read. Used for the degradation tests.
|
||||
err error
|
||||
// Call counters let the tests assert the batch fan-out (no N+1).
|
||||
peerCalls int
|
||||
batchCalls int
|
||||
settingsCalls int
|
||||
settingsBatchCalls int
|
||||
setCalls int
|
||||
// notifier mirrors production wiring: the application service owns the badge
|
||||
// push for all three drivers (this RPC, the bot dialog, the admin panel), so the
|
||||
// fake pushes here exactly where the real service does.
|
||||
notifier interface {
|
||||
NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error
|
||||
}
|
||||
}
|
||||
|
||||
func newFakeBotVerifications() *fakeBotVerifications {
|
||||
return &fakeBotVerifications{
|
||||
marks: make(map[domain.Peer]domain.CustomVerification),
|
||||
settings: make(map[int64]domain.BotVerifierSettings),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBotVerifications) PeerVerification(_ context.Context, peer domain.Peer) (domain.CustomVerification, error) {
|
||||
f.peerCalls++
|
||||
if f.err != nil {
|
||||
return domain.CustomVerification{}, f.err
|
||||
}
|
||||
mark, ok := f.marks[peer]
|
||||
if !ok {
|
||||
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
|
||||
}
|
||||
return mark, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotVerifications) PeerVerificationBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
|
||||
f.batchCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make(map[domain.Peer]domain.CustomVerification, len(peers))
|
||||
for _, peer := range peers {
|
||||
if mark, ok := f.marks[peer]; ok {
|
||||
out[peer] = mark
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotVerifications) VerifierSettings(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
||||
f.settingsCalls++
|
||||
if f.err != nil {
|
||||
return domain.BotVerifierSettings{}, f.err
|
||||
}
|
||||
settings, ok := f.settings[botID]
|
||||
if !ok {
|
||||
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotVerifications) VerifierSettingsBatch(_ context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) {
|
||||
f.settingsBatchCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make(map[int64]domain.BotVerifierSettings, len(botIDs))
|
||||
for _, id := range botIDs {
|
||||
if settings, ok := f.settings[id]; ok {
|
||||
out[id] = settings
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotVerifications) SetCustomVerification(_ context.Context, req domain.SetCustomVerificationRequest) (bool, error) {
|
||||
f.setCalls++
|
||||
if f.err != nil {
|
||||
return false, f.err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
settings, ok := f.settings[req.VerifierBotID]
|
||||
if !ok {
|
||||
return false, domain.ErrVerifierNotFound
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return false, domain.ErrVerifierForbidden
|
||||
}
|
||||
existing, marked := f.marks[req.Peer]
|
||||
if !req.Enabled {
|
||||
// A revoke only touches this verifier's own mark, and a repeated revoke is a
|
||||
// no-op rather than an error.
|
||||
if !marked || existing.VerifierBotID != req.VerifierBotID {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.marks, req.Peer)
|
||||
f.notify(req.Peer)
|
||||
return true, nil
|
||||
}
|
||||
description, err := settings.DescriptionFor(req.CustomDescription)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if marked && existing.VerifierBotID == req.VerifierBotID &&
|
||||
existing.IconDocumentID == settings.IconDocumentID && existing.Description == description {
|
||||
return false, nil
|
||||
}
|
||||
if f.limit > 0 && !marked && f.countFor(req.VerifierBotID) >= f.limit {
|
||||
return false, domain.ErrCustomVerificationLimit
|
||||
}
|
||||
f.marks[req.Peer] = domain.CustomVerification{
|
||||
VerifierBotID: req.VerifierBotID,
|
||||
Peer: req.Peer,
|
||||
IconDocumentID: settings.IconDocumentID,
|
||||
Description: description,
|
||||
GrantedByUserID: req.CallerUserID,
|
||||
}
|
||||
f.notify(req.Peer)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// notify reproduces the service's post-commit push.
|
||||
func (f *fakeBotVerifications) notify(peer domain.Peer) {
|
||||
if f.notifier == nil {
|
||||
return
|
||||
}
|
||||
_ = f.notifier.NotifyPeerBotVerification(context.Background(), peer)
|
||||
}
|
||||
|
||||
func (f *fakeBotVerifications) countFor(verifierBotID int64) int {
|
||||
n := 0
|
||||
for _, mark := range f.marks {
|
||||
if mark.VerifierBotID == verifierBotID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var _ BotVerificationService = (*fakeBotVerifications)(nil)
|
||||
|
||||
// botVerificationFixture wires the real users/bots/channels services next to the
|
||||
// fake verification service, so the ownership checks bots.setCustomVerification
|
||||
// depends on are the production ones.
|
||||
type botVerificationFixture struct {
|
||||
router *Router
|
||||
verify *fakeBotVerifications
|
||||
bots *botsapp.Service
|
||||
owner domain.User
|
||||
stranger domain.User
|
||||
target domain.User
|
||||
bot domain.User
|
||||
foreign domain.User
|
||||
}
|
||||
|
||||
func newBotVerificationFixture(t *testing.T, verify BotVerificationService) botVerificationFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
botStore := memory.NewBotStore(userStore)
|
||||
dialogs := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogs)
|
||||
bots := botsapp.NewService(userStore, botStore, messageStore)
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
|
||||
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
stranger, err := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Stranger"})
|
||||
if err != nil {
|
||||
t.Fatalf("create stranger: %v", err)
|
||||
}
|
||||
target, err := userStore.Create(ctx, domain.User{AccessHash: 9103, Phone: "15550009103", FirstName: "Target", Username: "target_shop"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
bot, _, err := bots.CreateBot(ctx, owner.ID, "Verifier Bot", "verifier_shape_bot")
|
||||
if err != nil {
|
||||
t.Fatalf("create verifier bot: %v", err)
|
||||
}
|
||||
foreign, _, err := bots.CreateBot(ctx, stranger.ID, "Foreign Bot", "foreign_shape_bot")
|
||||
if err != nil {
|
||||
t.Fatalf("create foreign bot: %v", err)
|
||||
}
|
||||
fake, _ := verify.(*fakeBotVerifications)
|
||||
deps := Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Bots: bots,
|
||||
Channels: channels,
|
||||
}
|
||||
if verify != nil {
|
||||
deps.BotVerifications = verify
|
||||
}
|
||||
return botVerificationFixture{
|
||||
router: New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, deps, zaptest.NewLogger(t), clock.System),
|
||||
verify: fake,
|
||||
bots: bots,
|
||||
owner: owner,
|
||||
stranger: stranger,
|
||||
target: target,
|
||||
bot: bot,
|
||||
foreign: foreign,
|
||||
}
|
||||
}
|
||||
|
||||
// enableVerifier grants the fake verifier status the way the operator would.
|
||||
func (f botVerificationFixture) enableVerifier(botID, icon int64, canModifyDescription bool) {
|
||||
f.verify.settings[botID] = domain.BotVerifierSettings{
|
||||
BotID: botID,
|
||||
IconDocumentID: icon,
|
||||
CompanyName: "Acme Trust",
|
||||
DefaultDescription: "Verified by Acme Trust",
|
||||
CanModifyCustomDescription: canModifyDescription,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func setCustomVerificationRequest(peer tg.InputPeerClass, bot tg.InputUserClass, enabled bool, description string) *tg.BotsSetCustomVerificationRequest {
|
||||
req := &tg.BotsSetCustomVerificationRequest{Peer: peer}
|
||||
if bot != nil {
|
||||
req.SetBot(bot)
|
||||
}
|
||||
req.SetEnabled(enabled)
|
||||
if description != "" {
|
||||
req.SetCustomDescription(description)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationOwnerGrantsAndRevokes is the main happy path: the
|
||||
// owner of a verifier bot marks a peer, the repeat call remains successful, and
|
||||
// the revoke removes exactly that mark.
|
||||
//
|
||||
// The Bool result is asserted rather than checking only err == nil: official
|
||||
// clients require BoolTrue even when the requested state was already applied.
|
||||
func TestBotsSetCustomVerificationOwnerGrantsAndRevokes(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.bot.ID, 5550001, true)
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("owner grant = %v,%v, want true,nil", ok, err)
|
||||
}
|
||||
mark, exists := f.verify.marks[peer]
|
||||
if !exists {
|
||||
t.Fatalf("grant stored nothing: %+v", f.verify.marks)
|
||||
}
|
||||
if mark.VerifierBotID != f.bot.ID || mark.IconDocumentID != 5550001 {
|
||||
t.Fatalf("stored mark = %+v, want verifier %d icon 5550001", mark, f.bot.ID)
|
||||
}
|
||||
if mark.Description != "Verified by Acme Trust" {
|
||||
t.Fatalf("stored description = %q, want the verifier default", mark.Description)
|
||||
}
|
||||
if mark.GrantedByUserID != f.owner.ID {
|
||||
t.Fatalf("granted by = %d, want calling owner %d", mark.GrantedByUserID, f.owner.ID)
|
||||
}
|
||||
|
||||
// The method reports successful processing, including an idempotent replay.
|
||||
// Official clients treat BoolFalse as failure and do not infer "unchanged".
|
||||
repeat, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if err != nil || !repeat {
|
||||
t.Fatalf("repeated grant = %v,%v, want true,nil", repeat, err)
|
||||
}
|
||||
|
||||
revoked, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
|
||||
if err != nil || !revoked {
|
||||
t.Fatalf("revoke = %v,%v, want true,nil", revoked, err)
|
||||
}
|
||||
if _, exists := f.verify.marks[peer]; exists {
|
||||
t.Fatalf("revoke left the mark behind: %+v", f.verify.marks)
|
||||
}
|
||||
repeatRevoke, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, ""))
|
||||
if err != nil || !repeatRevoke {
|
||||
t.Fatalf("repeated revoke = %v,%v, want true,nil", repeatRevoke, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationBotCallerActsAsItself covers the other caller the TL
|
||||
// constructor allows: a bot invoking without bot:flags.0, which must resolve to the
|
||||
// calling bot's own id.
|
||||
func TestBotsSetCustomVerificationBotCallerActsAsItself(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.bot.ID, 5550002, true)
|
||||
botCtx := WithUserID(context.Background(), f.bot.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(botCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, ""))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("bot self grant = %v,%v, want true,nil", ok, err)
|
||||
}
|
||||
mark := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}]
|
||||
if mark.VerifierBotID != f.bot.ID {
|
||||
t.Fatalf("verifier id = %d, want the calling bot %d", mark.VerifierBotID, f.bot.ID)
|
||||
}
|
||||
if mark.GrantedByUserID != f.bot.ID {
|
||||
t.Fatalf("granted by = %d, want the calling bot %d", mark.GrantedByUserID, f.bot.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationRejectsForeignBot pins the ownership boundary: naming
|
||||
// somebody else's bot is BOT_INVALID, and nothing reaches the service.
|
||||
func TestBotsSetCustomVerificationRejectsForeignBot(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.foreign.ID, 5550003, true)
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, ""))
|
||||
if ok || !tgerr.Is(err, "BOT_INVALID") {
|
||||
t.Fatalf("foreign bot = %v,%v, want false,BOT_INVALID", ok, err)
|
||||
}
|
||||
if f.verify.setCalls != 0 {
|
||||
t.Fatalf("foreign bot reached the service %d times", f.verify.setCalls)
|
||||
}
|
||||
if len(f.verify.marks) != 0 {
|
||||
t.Fatalf("foreign bot minted a mark: %+v", f.verify.marks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationRejectsNonVerifier covers a bot the operator never
|
||||
// granted verifier status, and a verifier the operator switched off: both are
|
||||
// BOT_VERIFIER_FORBIDDEN, because in both cases the bot may not verify anything.
|
||||
func TestBotsSetCustomVerificationRejectsNonVerifier(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("non-verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
|
||||
disabled := f.verify.settings
|
||||
f.enableVerifier(f.bot.ID, 5550004, true)
|
||||
settings := disabled[f.bot.ID]
|
||||
settings.Enabled = false
|
||||
disabled[f.bot.ID] = settings
|
||||
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("disabled verifier = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
if len(f.verify.marks) != 0 {
|
||||
t.Fatalf("forbidden verifier minted a mark: %+v", f.verify.marks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationRejectsInvalidPeer covers both invalid-target paths:
|
||||
// an unresolvable inputPeer at the edge, and a peer the domain refuses to verify.
|
||||
// Both must read PEER_ID_INVALID to a client -- the verifier is fine, the target is
|
||||
// not.
|
||||
func TestBotsSetCustomVerificationRejectsInvalidPeer(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.bot.ID, 5550005, true)
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, ""))
|
||||
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
|
||||
t.Fatalf("empty peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
|
||||
}
|
||||
if f.verify.setCalls != 0 {
|
||||
t.Fatalf("empty peer reached the service %d times", f.verify.setCalls)
|
||||
}
|
||||
|
||||
// A stored-state rejection maps to the same code, so a client cannot tell the
|
||||
// two apart and cannot probe which peers exist.
|
||||
f.verify.err = domain.ErrCustomVerificationTargetInvalid
|
||||
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
|
||||
t.Fatalf("domain-rejected peer = %v,%v, want false,PEER_ID_INVALID", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationRejectsForbiddenDescription pins the
|
||||
// can_modify_custom_description permission: a verifier that may only apply the
|
||||
// operator default is refused when it supplies its own text, and the refusal is a
|
||||
// documented BOT_VERIFIER_FORBIDDEN error.
|
||||
func TestBotsSetCustomVerificationRejectsForbiddenDescription(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.bot.ID, 5550006, false)
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Hand-written blurb"))
|
||||
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("forbidden description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
if code, _ := tgerr.AsType(err, "BOT_VERIFIER_FORBIDDEN"); code == nil || code.Code != 403 {
|
||||
t.Fatalf("forbidden description error = %+v, want code 403", code)
|
||||
}
|
||||
if len(f.verify.marks) != 0 {
|
||||
t.Fatalf("rejected description still minted a mark: %+v", f.verify.marks)
|
||||
}
|
||||
|
||||
// Without the description the very same call goes through with the default text.
|
||||
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, ""))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("default description = %v,%v, want true,nil", ok, err)
|
||||
}
|
||||
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Verified by Acme Trust" {
|
||||
t.Fatalf("stored description = %q, want the operator default", got)
|
||||
}
|
||||
|
||||
// A verifier that IS allowed to override stores its own text.
|
||||
f.enableVerifier(f.bot.ID, 5550006, true)
|
||||
ok, err = f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "Official reseller"))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("custom description = %v,%v, want true,nil", ok, err)
|
||||
}
|
||||
if got := f.verify.marks[domain.Peer{Type: domain.PeerTypeUser, ID: f.target.ID}].Description; got != "Official reseller" {
|
||||
t.Fatalf("stored description = %q, want the verifier text", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationRejectsRevokeWithDescription pins the edge-side shape
|
||||
// validation: a revoke that still carries a description is a caller bug (usually a
|
||||
// forgotten enabled flag) and is reported rather than silently reinterpreted.
|
||||
func TestBotsSetCustomVerificationRejectsRevokeWithDescription(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.bot.ID, 5550007, true)
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), false, "why"))
|
||||
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("revoke with description = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
if f.verify.setCalls != 0 {
|
||||
t.Fatalf("malformed request reached the service %d times", f.verify.setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationReportsLimit keeps the local per-verifier bound
|
||||
// behind the only applicable documented method error.
|
||||
func TestBotsSetCustomVerificationReportsLimit(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
f.enableVerifier(f.bot.ID, 5550008, true)
|
||||
f.verify.limit = 1
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); err != nil || !ok {
|
||||
t.Fatalf("first grant = %v,%v, want true,nil", ok, err)
|
||||
}
|
||||
ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.stranger), inputUser(f.bot), true, ""))
|
||||
if ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("over-limit grant = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotsSetCustomVerificationRejectsNilRequest(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, newFakeBotVerifications())
|
||||
ok, err := f.router.onBotsSetCustomVerification(
|
||||
WithUserID(context.Background(), f.owner.ID),
|
||||
nil,
|
||||
)
|
||||
if ok || !tgerr.Is(err, "PEER_ID_INVALID") {
|
||||
t.Fatalf("nil request = %v,%v, want false,PEER_ID_INVALID", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsSetCustomVerificationWithoutServiceStaysForbidden is the degradation half
|
||||
// of the RPC: with no verification service wired no bot can be a verifier, so the
|
||||
// answer must be exactly what it was before the feature existed.
|
||||
func TestBotsSetCustomVerificationWithoutServiceStaysForbidden(t *testing.T) {
|
||||
f := newBotVerificationFixture(t, nil)
|
||||
if f.router.deps.BotVerifications != nil {
|
||||
t.Fatal("fixture wired a verification service, want nil")
|
||||
}
|
||||
ownerCtx := WithUserID(context.Background(), f.owner.ID)
|
||||
botCtx := WithUserID(context.Background(), f.bot.ID)
|
||||
|
||||
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.bot), true, "")); ok ||
|
||||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("owner call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
if ok, err := f.router.onBotsSetCustomVerification(botCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), nil, true, "")); ok ||
|
||||
!tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
|
||||
t.Fatalf("bot call without service = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
|
||||
}
|
||||
// The ownership and peer checks still run first, so their codes are unchanged too.
|
||||
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(inputPeerUser(f.target), inputUser(f.foreign), true, "")); ok ||
|
||||
!tgerr.Is(err, "BOT_INVALID") {
|
||||
t.Fatalf("foreign bot without service = %v,%v, want false,BOT_INVALID", ok, err)
|
||||
}
|
||||
if ok, err := f.router.onBotsSetCustomVerification(ownerCtx,
|
||||
setCustomVerificationRequest(&tg.InputPeerEmpty{}, inputUser(f.bot), true, "")); ok ||
|
||||
!tgerr.Is(err, "PEER_ID_INVALID") {
|
||||
t.Fatalf("empty peer without service = %v,%v, want false,PEER_ID_INVALID", ok, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,25 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
}
|
||||
botUserID := callback.BotUserID
|
||||
|
||||
// 内置(进程内)service bot 分支:@verifybot 这类 bot 没有 MTProto session、也没有
|
||||
// Bot API 消费者,走下面的「推 updateBotCallbackQuery + 挂起 25s」必然超时回
|
||||
// BOT_RESPONSE_TIMEOUT。因此在 registerContext / 推送之前同步问 responder:点击本身
|
||||
// 已由 resolveBotCallbackQuery 校验过(消息在请求者自己的盒里、对端正是该 bot、data
|
||||
// 确实出现在该消息的 inline keyboard 中),此处只是把答案交给拥有该 bot 的实现。
|
||||
// 不注册 query id:外部 setBotCallbackAnswer 因此无法伪造/覆盖内置 bot 的应答。
|
||||
if r.deps.ServiceBotCallbacks != nil && r.deps.ServiceBotCallbacks.HandlesBot(botUserID) {
|
||||
ans, handled, err := r.deps.ServiceBotCallbacks.OnCallbackQuery(ctx, callback)
|
||||
if err != nil {
|
||||
r.log.Warn("service bot callback query",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !handled {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
}
|
||||
|
||||
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
|
||||
if err != nil {
|
||||
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
|
|
|
|||
|
|
@ -28,6 +28,31 @@ func rightsNotModifiedErr() error { return tgerr.New(400, "RIGHTS_NOT_MOD
|
|||
func botVerifierForbiddenErr() error { return tgerr.New(403, "BOT_VERIFIER_FORBIDDEN") }
|
||||
func userPermissionDeniedErr() error { return tgerr.New(403, "USER_PERMISSION_DENIED") }
|
||||
|
||||
// setCustomVerificationErr maps the third-party verification domain errors onto TL.
|
||||
//
|
||||
// The public method documents only BOT_INVALID, BOT_VERIFIER_FORBIDDEN and
|
||||
// PEER_ID_INVALID. Domain detail must not leak invented error names that official
|
||||
// clients do not handle.
|
||||
func setCustomVerificationErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrVerifierForbidden),
|
||||
errors.Is(err, domain.ErrVerifierNotFound),
|
||||
errors.Is(err, domain.ErrVerificationIconNotFound),
|
||||
errors.Is(err, domain.ErrVerificationIconInactive),
|
||||
errors.Is(err, domain.ErrVerificationIconInvalid),
|
||||
errors.Is(err, domain.ErrVerifierDescriptionForbidden),
|
||||
errors.Is(err, domain.ErrCustomVerificationRequestInvalid),
|
||||
errors.Is(err, domain.ErrCustomVerificationLimit):
|
||||
return botVerifierForbiddenErr()
|
||||
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
|
||||
return peerIDInvalidErr()
|
||||
case errors.Is(err, domain.ErrBotNotFound), errors.Is(err, domain.ErrVerifierSettingsInvalid):
|
||||
return botInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func setBotCommandsErr(err error) error {
|
||||
if errors.Is(err, domain.ErrBotCommandInvalid) {
|
||||
return botCommandInvalidErr()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 记录最近一次推送,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = ©
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
146
internal/rpc/channels_invites_verified_test.go
Normal file
146
internal/rpc/channels_invites_verified_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// inviteBadgeChannels answers messages.checkChatInvite from a canned persistent
|
||||
// channel record, so the test observes exactly the flags the projection derives.
|
||||
type inviteBadgeChannels struct {
|
||||
ChannelsService
|
||||
result domain.CheckChannelInviteResult
|
||||
}
|
||||
|
||||
func (s *inviteBadgeChannels) CheckInvite(_ context.Context, _ int64, _ string, _ int) (domain.CheckChannelInviteResult, error) {
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
func checkChatInvitePreview(t *testing.T, ch domain.Channel) *tg.ChatInvite {
|
||||
t.Helper()
|
||||
r := New(Config{}, Deps{Channels: &inviteBadgeChannels{
|
||||
result: domain.CheckChannelInviteResult{
|
||||
Channel: ch,
|
||||
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: ch.ID},
|
||||
},
|
||||
}}, zap.NewNop(), clock.System)
|
||||
res, err := r.onMessagesCheckChatInvite(WithUserID(context.Background(), 1001), "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("check chat invite: %v", err)
|
||||
}
|
||||
invite, ok := res.(*tg.ChatInvite)
|
||||
if !ok {
|
||||
t.Fatalf("invite = %T %+v", res, res)
|
||||
}
|
||||
// Round-trip through the wire so the assertions read the encoded flags word
|
||||
// rather than only the Go struct fields: a raw field assignment would pass the
|
||||
// struct check and still ship flags.7/8/9 unset.
|
||||
buf := &bin.Buffer{}
|
||||
if err := invite.Encode(buf); err != nil {
|
||||
t.Fatalf("encode chatInvite: %v", err)
|
||||
}
|
||||
decoded := &tg.ChatInvite{}
|
||||
if err := decoded.Decode(buf); err != nil {
|
||||
t.Fatalf("decode chatInvite: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// TestCheckChatInvitePreviewCarriesLayer228BadgeFlags pins chatInvite#5c9d3702
|
||||
// verified:flags.7 / scam:flags.8 / fake:flags.9 onto the invite preview, so an
|
||||
// official client shows the badge before the user joins rather than after.
|
||||
func TestCheckChatInvitePreviewCarriesLayer228BadgeFlags(t *testing.T) {
|
||||
if tg.ChatInviteTypeID != 0x5c9d3702 {
|
||||
t.Fatalf("chatInvite constructor id = %#x", tg.ChatInviteTypeID)
|
||||
}
|
||||
|
||||
verified := checkChatInvitePreview(t, domain.Channel{
|
||||
ID: 4004, AccessHash: 44, Title: "Official", Username: "official",
|
||||
Broadcast: true, ParticipantsCount: 7, Verified: true,
|
||||
})
|
||||
if !verified.GetVerified() || !verified.Verified {
|
||||
t.Fatalf("verified invite = %+v", verified)
|
||||
}
|
||||
if verified.GetScam() || verified.GetFake() {
|
||||
t.Fatalf("verified invite leaked moderation flags: %+v", verified)
|
||||
}
|
||||
if !verified.Channel || !verified.Broadcast || !verified.Public ||
|
||||
verified.Title != "Official" || verified.ParticipantsCount != 7 {
|
||||
t.Fatalf("verified invite lost unrelated fields: %+v", verified)
|
||||
}
|
||||
|
||||
flagged := checkChatInvitePreview(t, domain.Channel{
|
||||
ID: 4005, AccessHash: 45, Title: "Flagged", Megagroup: true,
|
||||
Scam: true, Fake: true,
|
||||
})
|
||||
if !flagged.GetScam() || !flagged.GetFake() {
|
||||
t.Fatalf("flagged invite = %+v", flagged)
|
||||
}
|
||||
if flagged.GetVerified() {
|
||||
t.Fatalf("flagged invite claims verification: %+v", flagged)
|
||||
}
|
||||
if !flagged.Megagroup || flagged.Public {
|
||||
t.Fatalf("flagged invite lost unrelated fields: %+v", flagged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckChatInvitePreviewLeavesUnflaggedChannelClean is the negative half: an
|
||||
// ordinary channel must not carry flags.7/8/9 at all, so the encoded preview stays
|
||||
// byte-identical to what an official server sends for an unflagged peer.
|
||||
func TestCheckChatInvitePreviewLeavesUnflaggedChannelClean(t *testing.T) {
|
||||
plain := checkChatInvitePreview(t, domain.Channel{
|
||||
ID: 4006, AccessHash: 46, Title: "Plain", Broadcast: true,
|
||||
})
|
||||
if plain.GetVerified() || plain.Verified {
|
||||
t.Fatalf("unverified channel exposed verified: %+v", plain)
|
||||
}
|
||||
if plain.GetScam() || plain.GetFake() || plain.Scam || plain.Fake {
|
||||
t.Fatalf("unflagged channel exposed moderation flags: %+v", plain)
|
||||
}
|
||||
if plain.Flags.Has(7) || plain.Flags.Has(8) || plain.Flags.Has(9) {
|
||||
t.Fatalf("unflagged channel set badge bits in flags word: %d", plain.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckChatInviteAlreadyKeepsChannelBadge guards the other branch: a member
|
||||
// gets chatInviteAlready#5a686d7c, whose Chat already carries
|
||||
// channel#d49f34c6 verified:flags.7 through tgChannelChat, so the flags must not be
|
||||
// applied twice or lost.
|
||||
func TestCheckChatInviteAlreadyKeepsChannelBadge(t *testing.T) {
|
||||
const channelID = int64(4007)
|
||||
r := New(Config{}, Deps{Channels: &inviteBadgeChannels{
|
||||
result: domain.CheckChannelInviteResult{
|
||||
Channel: domain.Channel{
|
||||
ID: channelID, AccessHash: 47, Title: "Official", Username: "official",
|
||||
Broadcast: true, Verified: true,
|
||||
},
|
||||
Invite: domain.ChannelInvite{Hash: "hash", ChannelID: channelID},
|
||||
Already: true,
|
||||
Self: domain.ChannelMember{
|
||||
ChannelID: channelID, UserID: 1001, Status: domain.ChannelMemberActive,
|
||||
},
|
||||
},
|
||||
}}, zap.NewNop(), clock.System)
|
||||
res, err := r.onMessagesCheckChatInvite(WithUserID(context.Background(), 1001), "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("check chat invite: %v", err)
|
||||
}
|
||||
already, ok := res.(*tg.ChatInviteAlready)
|
||||
if !ok {
|
||||
t.Fatalf("invite = %T %+v", res, res)
|
||||
}
|
||||
channel, ok := already.Chat.(*tg.Channel)
|
||||
if !ok || channel.ID != channelID {
|
||||
t.Fatalf("chat = %T %+v", already.Chat, already.Chat)
|
||||
}
|
||||
if !channel.Verified || channel.Scam || channel.Fake {
|
||||
t.Fatalf("already-member channel badge = %+v", channel)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1168
internal/rpc/collectible_usernames_rpc_test.go
Normal file
1168
internal/rpc/collectible_usernames_rpc_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
30
internal/rpc/convert_history_clear_test.go
Normal file
30
internal/rpc/convert_history_clear_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
398
internal/rpc/fragment.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
176
internal/rpc/help_premium_promo_test.go
Normal file
176
internal/rpc/help_premium_promo_test.go
Normal 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,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const (
|
|||
maxReactionVector = 16
|
||||
maxReactionListOffset = 128
|
||||
maxReportOptionLength = 32
|
||||
maxReportCommentLength = 1024
|
||||
maxReportCommentLength = domain.MaxModerationCommentRunes
|
||||
maxReportRandomIDLength = 128
|
||||
maxReadMetrics = 100
|
||||
maxBusinessConnIDLength = 128
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 自身直接投影,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
144
internal/rpc/messages_no_forwards.go
Normal file
144
internal/rpc/messages_no_forwards.go
Normal 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)
|
||||
}
|
||||
167
internal/rpc/messages_no_forwards_rpc_test.go
Normal file
167
internal/rpc/messages_no_forwards_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue