chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,566 @@
package rpc
import (
"context"
"errors"
"strings"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) accountBusinessAutomation() (AccountBusinessAutomationService, bool) {
if r.deps.Account == nil {
return nil, false
}
svc, ok := r.deps.Account.(AccountBusinessAutomationService)
return svc, ok
}
func (r *Router) onAccountUpdateBusinessWorkHours(ctx context.Context, req *tg.AccountUpdateBusinessWorkHoursRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, premiumAccountRequiredErr()
}
hours, err := domainBusinessWorkHours(req)
if err != nil {
return false, err
}
if _, err := svc.UpdateBusinessWorkHours(ctx, userID, hours); err != nil {
return false, businessAutomationErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
func (r *Router) onAccountUpdateBusinessLocation(ctx context.Context, req *tg.AccountUpdateBusinessLocationRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, premiumAccountRequiredErr()
}
location, err := domainBusinessLocation(req)
if err != nil {
return false, err
}
if _, err := svc.UpdateBusinessLocation(ctx, userID, location); err != nil {
return false, businessAutomationErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
func (r *Router) onAccountUpdateBusinessIntro(ctx context.Context, req *tg.AccountUpdateBusinessIntroRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, premiumAccountRequiredErr()
}
intro, err := domainBusinessIntro(req)
if err != nil {
return false, err
}
if _, err := svc.UpdateBusinessIntro(ctx, userID, intro); err != nil {
return false, businessAutomationErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
func (r *Router) onAccountUpdateBusinessGreetingMessage(ctx context.Context, req *tg.AccountUpdateBusinessGreetingMessageRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, premiumAccountRequiredErr()
}
greeting, err := r.domainBusinessGreeting(ctx, userID, req)
if err != nil {
return false, err
}
if _, err := svc.UpdateBusinessGreetingMessage(ctx, userID, greeting); err != nil {
return false, businessAutomationErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
func (r *Router) onAccountUpdateBusinessAwayMessage(ctx context.Context, req *tg.AccountUpdateBusinessAwayMessageRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, premiumAccountRequiredErr()
}
away, err := r.domainBusinessAway(ctx, userID, req)
if err != nil {
return false, err
}
if _, err := svc.UpdateBusinessAwayMessage(ctx, userID, away); err != nil {
return false, businessAutomationErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
func (r *Router) onAccountGetBusinessChatLinks(ctx context.Context) (*tg.AccountBusinessChatLinks, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return &tg.AccountBusinessChatLinks{
Links: []tg.BusinessChatLink{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}, nil
}
links, err := svc.ListBusinessChatLinks(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.AccountBusinessChatLinks{
Links: tgBusinessChatLinks(links),
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}, nil
}
func (r *Router) onAccountCreateBusinessChatLink(ctx context.Context, link tg.InputBusinessChatLink) (*tg.BusinessChatLink, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return nil, premiumAccountRequiredErr()
}
input, err := domainBusinessChatLinkInput(link)
if err != nil {
return nil, err
}
created, err := svc.CreateBusinessChatLink(ctx, userID, input)
if err != nil {
return nil, businessAutomationErr(err)
}
out := tgBusinessChatLink(created)
return &out, nil
}
func (r *Router) onAccountEditBusinessChatLink(ctx context.Context, req *tg.AccountEditBusinessChatLinkRequest) (*tg.BusinessChatLink, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req == nil || strings.TrimSpace(req.Slug) == "" {
return nil, chatlinkSlugEmptyErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return nil, premiumAccountRequiredErr()
}
input, err := domainBusinessChatLinkInput(req.Link)
if err != nil {
return nil, err
}
updated, err := svc.EditBusinessChatLink(ctx, userID, req.Slug, input)
if err != nil {
return nil, businessAutomationErr(err)
}
out := tgBusinessChatLink(updated)
return &out, nil
}
func (r *Router) onAccountDeleteBusinessChatLink(ctx context.Context, slug string) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if strings.TrimSpace(slug) == "" {
return false, chatlinkSlugEmptyErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, chatlinkSlugExpiredErr()
}
deleted, err := svc.DeleteBusinessChatLink(ctx, userID, slug)
if err != nil {
return false, businessAutomationErr(err)
}
if !deleted {
return false, chatlinkSlugExpiredErr()
}
return true, nil
}
func (r *Router) onAccountResolveBusinessChatLink(ctx context.Context, slug string) (*tg.AccountResolvedBusinessChatLinks, error) {
if strings.TrimSpace(slug) == "" {
return nil, chatlinkSlugEmptyErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return nil, chatlinkSlugExpiredErr()
}
link, found, err := svc.ResolveBusinessChatLink(ctx, slug, true)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, chatlinkSlugExpiredErr()
}
viewerID, _, _ := r.currentUserID(ctx)
users := []tg.UserClass{}
if r.deps.Users != nil {
user, found, err := r.deps.Users.ByID(ctx, viewerID, link.OwnerUserID)
if err != nil {
return nil, internalErr()
}
if found {
if viewerID != 0 && user.ID == viewerID {
users = append(users, r.tgSelfUser(user))
} else {
users = append(users, r.tgUser(user))
}
}
}
return &tg.AccountResolvedBusinessChatLinks{
Peer: &tg.PeerUser{UserID: link.OwnerUserID},
Message: link.Message,
Entities: tgMessageEntities(link.Entities),
Chats: []tg.ChatClass{},
Users: users,
}, nil
}
func (r *Router) onAccountGetConnectedBots(ctx context.Context) (*tg.AccountConnectedBots, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil
}
bot, found, err := svc.GetConnectedBusinessBot(ctx, userID)
if err != nil {
return nil, internalErr()
}
if !found || bot.BotUserID == 0 {
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil
}
botUser, found, err := r.connectedBusinessBotByID(ctx, userID, bot.BotUserID)
if err != nil {
return nil, err
}
if !found {
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil
}
return &tg.AccountConnectedBots{
ConnectedBots: []tg.ConnectedBot{tgConnectedBot(bot)},
Users: []tg.UserClass{r.tgUser(botUser)},
}, nil
}
func (r *Router) onAccountUpdateConnectedBot(ctx context.Context, req *tg.AccountUpdateConnectedBotRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return nil, premiumAccountRequiredErr()
}
botUser, found, err := r.connectedBusinessBotFromInput(ctx, userID, req.Bot)
if err != nil {
return nil, err
}
if !found {
return nil, botBusinessMissingErr()
}
if req.Deleted {
if _, err := svc.DeleteConnectedBusinessBot(ctx, userID, botUser.ID); err != nil {
return nil, businessAutomationErr(err)
}
r.invalidateRPCProjectionForViewer(userID)
return r.connectedBusinessBotEmptyUpdates(botUser), nil
}
recipients, err := r.domainBusinessBotRecipients(ctx, userID, req.Recipients)
if err != nil {
return nil, err
}
saved, err := svc.SaveConnectedBusinessBot(ctx, userID, domain.ConnectedBusinessBot{
BotUserID: botUser.ID,
Recipients: recipients,
Rights: domainBusinessBotRightsForUpdate(req),
})
if err != nil {
return nil, businessAutomationErr(err)
}
r.invalidateRPCProjectionForViewer(userID)
return r.connectedBusinessBotEmptyUpdates(botUser, tgConnectedBot(saved)), nil
}
func (r *Router) onAccountToggleConnectedBotPaused(ctx context.Context, req *tg.AccountToggleConnectedBotPausedRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if peer.Type != domain.PeerTypeUser || peer.ID == userID {
return false, peerIDInvalidErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, botBusinessMissingErr()
}
if _, err := svc.SetConnectedBusinessBotPaused(ctx, userID, peer.ID, req.Paused); err != nil {
return false, businessAutomationErr(err)
}
settings, err := r.connectedBusinessBotPeerSettings(ctx, userID, peer, domain.PeerSettings{})
if err != nil {
return false, err
}
if err := r.recordConnectedBusinessPeerSettings(ctx, userID, peer, settings); err != nil {
return false, internalErr()
}
r.invalidateRPCProjectionForPeer(userID, peer)
return true, nil
}
func (r *Router) onAccountDisablePeerConnectedBot(ctx context.Context, input tg.InputPeerClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return false, err
}
if peer.Type != domain.PeerTypeUser || peer.ID == userID {
return false, peerIDInvalidErr()
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return false, botBusinessMissingErr()
}
if _, err := svc.DisableConnectedBusinessBotForPeer(ctx, userID, peer.ID); err != nil {
return false, businessAutomationErr(err)
}
if err := r.recordConnectedBusinessPeerSettings(ctx, userID, peer, domain.PeerSettings{}); err != nil {
return false, internalErr()
}
r.invalidateRPCProjectionForPeer(userID, peer)
return true, nil
}
func (r *Router) connectedBusinessBotByID(ctx context.Context, currentUserID, botUserID int64) (domain.User, bool, error) {
if r.deps.Users == nil || botUserID == 0 {
return domain.User{}, false, nil
}
u, found, err := r.deps.Users.ByID(ctx, currentUserID, botUserID)
if err != nil {
return domain.User{}, false, internalErr()
}
if !found || !connectedBusinessBotUsable(u) {
return domain.User{}, false, nil
}
return u, true, nil
}
func (r *Router) connectedBusinessBotFromInput(ctx context.Context, currentUserID int64, input tg.InputUserClass) (domain.User, bool, error) {
if r.deps.Users == nil {
return domain.User{}, false, internalErr()
}
u, found, err := r.userFromInput(ctx, currentUserID, input)
if err != nil {
return domain.User{}, false, internalErr()
}
if !found || !connectedBusinessBotUsable(u) {
return domain.User{}, false, nil
}
return u, true, nil
}
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 {
users := []tg.UserClass{}
if botUser.ID != 0 {
users = append(users, r.tgUser(botUser))
}
return &tg.Updates{
Updates: []tg.UpdateClass{},
Users: users,
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
}
}
func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUserID int64, peer domain.Peer, settings domain.PeerSettings) (domain.PeerSettings, error) {
if peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == ownerUserID {
return settings, nil
}
svc, ok := r.accountBusinessAutomation()
if !ok {
return settings, nil
}
bot, found, err := svc.GetConnectedBusinessBot(ctx, ownerUserID)
if err != nil {
return domain.PeerSettings{}, internalErr()
}
if !found || bot.BotUserID == 0 {
return settings, nil
}
state, stateFound, err := svc.GetConnectedBusinessBotPeerState(ctx, ownerUserID, peer.ID)
if err != nil {
return domain.PeerSettings{}, internalErr()
}
if stateFound && state.Disabled {
return settings, nil
}
existingChat, isContact := r.connectedBusinessPeerFacts(ctx, ownerUserID, peer.ID)
if !domain.BusinessBotRecipientsMatch(bot.Recipients, existingChat, isContact, peer.ID) {
return settings, nil
}
settings.BusinessBotID = bot.BotUserID
settings.BusinessBotPaused = stateFound && state.Paused
settings.BusinessBotCanReply = bot.Rights.Reply && !settings.BusinessBotPaused
if botUser, found, err := r.connectedBusinessBotByID(ctx, ownerUserID, bot.BotUserID); err != nil {
return domain.PeerSettings{}, err
} else if found {
settings.BusinessBotManageURL = connectedBusinessBotManageURL(botUser)
}
if settings.BusinessBotManageURL == "" {
settings.BusinessBotManageURL = "telesrv://business-bot"
}
return settings, nil
}
func (r *Router) connectedBusinessPeerFacts(ctx context.Context, ownerUserID, peerUserID int64) (existingChat, isContact bool) {
if r.deps.Dialogs != nil {
list, err := r.deps.Dialogs.GetPeerDialogs(ctx, ownerUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: peerUserID}})
if err == nil && len(list.Dialogs) > 0 && list.Dialogs[0].TopMessage > 0 {
existingChat = true
}
}
if r.deps.Contacts != nil {
settings, err := r.deps.Contacts.GetPeerSettings(ctx, ownerUserID, domain.Peer{Type: domain.PeerTypeUser, ID: peerUserID})
isContact = err == nil && !settings.AddContact
}
return existingChat, isContact
}
func connectedBusinessBotManageURL(bot domain.User) string {
if bot.Username == "" {
return ""
}
return "https://telesrv.net/" + bot.Username
}
func (r *Router) recordConnectedBusinessPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID)
if err != nil {
return err
}
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
return nil
}
func premiumAccountRequiredErr() error { return tgerr400("PREMIUM_ACCOUNT_REQUIRED") }
func botBusinessMissingErr() error { return tgerr400("BOT_BUSINESS_MISSING") }
func botNotConnectedYetErr() error { return tgerr400("BOT_NOT_CONNECTED_YET") }
func botAlreadyDisabledErr() error { return tgerr400("BOT_ALREADY_DISABLED") }
func chatlinkSlugEmptyErr() error { return tgerr400("CHATLINK_SLUG_EMPTY") }
func chatlinkSlugExpiredErr() error { return tgerr400("CHATLINK_SLUG_EXPIRED") }
func businessAutomationErr(err error) error {
switch {
case errors.Is(err, domain.ErrPremiumRequired):
return premiumAccountRequiredErr()
case errors.Is(err, domain.ErrBusinessProfileInvalid):
return inputRequestInvalidErr()
case errors.Is(err, domain.ErrBusinessRecipientsEmpty):
return tgerr400("BUSINESS_RECIPIENTS_EMPTY")
case errors.Is(err, domain.ErrBotBusinessMissing):
return botBusinessMissingErr()
case errors.Is(err, domain.ErrBotNotConnectedYet):
return botNotConnectedYetErr()
case errors.Is(err, domain.ErrBotAlreadyDisabled):
return botAlreadyDisabledErr()
case errors.Is(err, domain.ErrBusinessChatLinkInvalid):
return inputRequestInvalidErr()
case errors.Is(err, domain.ErrBusinessChatLinkNotFound):
return chatlinkSlugExpiredErr()
case errors.Is(err, domain.ErrBusinessChatLinksTooMuch):
return tgerr400("CHATLINKS_TOO_MUCH")
case errors.Is(err, domain.ErrShortcutInvalid):
return shortcutInvalidErr()
case errors.Is(err, domain.ErrShortcutOccupied):
return tgerr400("SHORTCUT_OCCUPIED")
case errors.Is(err, domain.ErrQuickRepliesTooMuch):
return tgerr400("SHORTCUTS_TOO_MUCH")
default:
return internalErr()
}
}
func (r *Router) applyBusinessProfileToUserFull(ctx context.Context, full *tg.UserFull, profile domain.BusinessProfile) {
if full == nil {
return
}
if hours, ok := tgBusinessWorkHours(profile.WorkHours); ok {
full.SetBusinessWorkHours(hours)
}
if location, ok := tgBusinessLocation(profile.Location); ok {
full.SetBusinessLocation(location)
}
if intro, ok := r.tgBusinessIntro(ctx, profile.Intro); ok {
full.SetBusinessIntro(intro)
}
if greeting, ok := tgBusinessGreeting(profile.Greeting); ok {
full.SetBusinessGreetingMessage(greeting)
}
if away, ok := tgBusinessAway(profile.Away); ok {
full.SetBusinessAwayMessage(away)
}
}

View file

@ -0,0 +1,147 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountUpdateColorPersistsExplicitZeroAndPushesSelfUser(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003201", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sessions := &captureSessions{}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
color := &tg.PeerColor{}
color.SetColor(0)
color.SetBackgroundEmojiID(123456)
req := &tg.AccountUpdateColorRequest{}
req.SetColor(color)
ok, err := router.onAccountUpdateColor(WithUserID(ctx, owner.ID), req)
if err != nil || !ok {
t.Fatalf("update color = ok %v err %v, want true/nil", ok, err)
}
saved, found, err := userStore.ByID(ctx, owner.ID)
if err != nil || !found {
t.Fatalf("load saved user found=%v err=%v", found, err)
}
assertDomainPeerColor(t, saved.Color, true, 0, 123456)
assertTgUserPeerColor(t, tgSelfUser(saved).GetColor, true, 0, 123456)
snap := sessions.snapshot()
if snap.userID != owner.ID {
t.Fatalf("push user = %d, want %d", snap.userID, owner.ID)
}
updates, ok := snap.message.(*tg.Updates)
if !ok {
t.Fatalf("pushed message = %T, want *tg.Updates", snap.message)
}
if len(updates.Users) != 1 {
t.Fatalf("pushed users = %d, want 1 self user", len(updates.Users))
}
pushedUser, ok := updates.Users[0].(*tg.User)
if !ok {
t.Fatalf("pushed user = %T, want *tg.User", updates.Users[0])
}
assertTgUserPeerColor(t, pushedUser.GetColor, true, 0, 123456)
}
func TestAccountUpdateColorProfileSetAndClear(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550003202", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
}, zaptest.NewLogger(t), clock.System)
color := &tg.PeerColor{}
color.SetColor(3)
color.SetBackgroundEmojiID(777)
req := &tg.AccountUpdateColorRequest{}
req.SetForProfile(true)
req.SetColor(color)
if ok, err := router.onAccountUpdateColor(WithUserID(ctx, owner.ID), req); err != nil || !ok {
t.Fatalf("update profile color = ok %v err %v, want true/nil", ok, err)
}
saved, _, _ := userStore.ByID(ctx, owner.ID)
assertDomainPeerColor(t, saved.ProfileColor, true, 3, 777)
assertTgUserPeerColor(t, tgSelfUser(saved).GetProfileColor, true, 3, 777)
clear := &tg.AccountUpdateColorRequest{}
clear.SetForProfile(true)
if ok, err := router.onAccountUpdateColor(WithUserID(ctx, owner.ID), clear); err != nil || !ok {
t.Fatalf("clear profile color = ok %v err %v, want true/nil", ok, err)
}
saved, _, _ = userStore.ByID(ctx, owner.ID)
if !saved.ProfileColor.Empty() {
t.Fatalf("profile color after clear = %+v, want empty", saved.ProfileColor)
}
if _, ok := tgSelfUser(saved).GetProfileColor(); ok {
t.Fatal("self user profile_color after clear must be absent")
}
}
func TestAccountUpdateColorRejectsUnsupportedInputs(t *testing.T) {
router := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)
invalid := &tg.PeerColor{}
invalid.SetColor(99)
req := &tg.AccountUpdateColorRequest{}
req.SetColor(invalid)
if ok, err := router.onAccountUpdateColor(ctx, req); ok || !tgerr.Is(err, "COLOR_INVALID") {
t.Fatalf("invalid color = ok %v err %v, want COLOR_INVALID", ok, err)
}
collectible := &tg.AccountUpdateColorRequest{}
collectible.SetColor(&tg.InputPeerColorCollectible{CollectibleID: 42})
if ok, err := router.onAccountUpdateColor(ctx, collectible); ok || !tgerr.Is(err, "COLOR_INVALID") {
t.Fatalf("collectible color = ok %v err %v, want COLOR_INVALID", ok, err)
}
}
func assertDomainPeerColor(t *testing.T, got domain.PeerColor, hasColor bool, color int, backgroundEmojiID int64) {
t.Helper()
if got.HasColor != hasColor || got.Color != color || got.BackgroundEmojiID != backgroundEmojiID {
t.Fatalf("domain peer color = %+v, want has=%v color=%d bg=%d", got, hasColor, color, backgroundEmojiID)
}
}
func assertTgUserPeerColor(t *testing.T, get func() (tg.PeerColorClass, bool), hasColor bool, color int, backgroundEmojiID int64) {
t.Helper()
got, ok := get()
if !ok {
t.Fatal("tg user missing peer color")
}
peerColor, ok := got.(*tg.PeerColor)
if !ok {
t.Fatalf("tg peer color = %T, want *tg.PeerColor", got)
}
gotColor, gotHasColor := peerColor.GetColor()
if gotHasColor != hasColor || gotColor != color {
t.Fatalf("tg peer color id = %d has=%v, want %d has=%v", gotColor, gotHasColor, color, hasColor)
}
gotBackgroundEmojiID, gotHasBackgroundEmojiID := peerColor.GetBackgroundEmojiID()
if gotBackgroundEmojiID != backgroundEmojiID || gotHasBackgroundEmojiID != (backgroundEmojiID != 0) {
t.Fatalf("tg peer color bg = %d has=%v, want %d", gotBackgroundEmojiID, gotHasBackgroundEmojiID, backgroundEmojiID)
}
}

View file

@ -0,0 +1,313 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// accountNotifySettingsService 是 per-scope 通知设置持久化的可选扩展,由
// *app/account.Service 实现。未接通时各 handler 回落历史默认tdesktop.NotifySettings
type accountNotifySettingsService interface {
GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, error)
SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error
ResetNotifySettings(ctx context.Context, ownerUserID int64) error
PeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error)
AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error)
ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error)
}
func (r *Router) accountNotifySvc() (accountNotifySettingsService, bool) {
svc, ok := r.deps.Account.(accountNotifySettingsService)
return svc, ok
}
// notifyScopeFromInput 把 InputNotifyPeer 解析为业务作用域。peer/forumTopic 需解析
// 出 domain.Peer不校验访问权限——通知偏好是请求者自己对某 peer 的设置)。
func (r *Router) notifyScopeFromInput(userID int64, in tg.InputNotifyPeerClass) (domain.NotifyScope, bool) {
switch p := in.(type) {
case *tg.InputNotifyUsers:
return domain.NotifyScope{Kind: domain.NotifyScopeUsers}, true
case *tg.InputNotifyChats:
return domain.NotifyScope{Kind: domain.NotifyScopeChats}, true
case *tg.InputNotifyBroadcasts:
return domain.NotifyScope{Kind: domain.NotifyScopeBroadcasts}, true
case *tg.InputNotifyPeer:
peer, ok := r.domainPeerFromInputPeer(userID, p.Peer)
if !ok {
return domain.NotifyScope{}, false
}
return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peer}, true
case *tg.InputNotifyForumTopic:
peer, ok := r.domainPeerFromInputPeer(userID, p.Peer)
if !ok {
return domain.NotifyScope{}, false
}
return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peer, TopicID: p.TopMsgID}, true
default:
return domain.NotifyScope{}, false
}
}
func (r *Router) onAccountGetNotifySettings(ctx context.Context, peer tg.InputNotifyPeerClass) (*tg.PeerNotifySettings, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
scope, ok := r.notifyScopeFromInput(userID, peer)
svc, hasSvc := r.accountNotifySvc()
if !ok || !hasSvc {
return tdesktop.NotifySettings(), nil
}
settings, err := svc.GetNotifySettings(ctx, userID, scope)
if err != nil {
return nil, internalErr()
}
return tgPeerNotifySettings(&settings), nil
}
func (r *Router) onAccountUpdateNotifySettings(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
scope, ok := r.notifyScopeFromInput(userID, req.Peer)
if !ok {
return false, peerIDInvalidErr()
}
settings := domainPeerNotifySettings(req.Settings)
if svc, ok := r.accountNotifySvc(); ok {
if err := svc.SaveNotifySettings(ctx, userID, scope, settings); err != nil {
return false, internalErr()
}
r.notifySettings.Delete(userID)
}
// 推 updateNotifySettings 给本人其它在线设备(多设备静音同步)。
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateNotifySettings{
Peer: tgNotifyPeer(scope),
NotifySettings: *tgPeerNotifySettings(&settings),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
})
return true, nil
}
func (r *Router) onAccountResetNotifySettings(ctx context.Context) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if svc, ok := r.accountNotifySvc(); ok {
if err := svc.ResetNotifySettings(ctx, userID); err != nil {
return false, internalErr()
}
r.notifySettings.Delete(userID)
}
return true, nil
}
// onAccountGetNotifyExceptions 返回"有自定义通知设置的会话"全局索引:每条异常一个
// updateNotifySettings附带被引用 peer 的 Users/Chats。默认无 compare 标志)只返
// 消息级异常mute/silent/show_previewscompare_stories 额外纳入仅 story 维度异常。
func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.AccountGetNotifyExceptionsRequest) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
empty := &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(r.clock.Now().Unix())}
svc, ok := r.accountNotifySvc()
if !ok {
return empty, nil
}
exceptions, err := svc.ListNotifyExceptions(ctx, userID)
if err != nil {
return nil, internalErr()
}
compareStories := req != nil && req.CompareStories
var filterPeer *domain.Peer
if req != nil {
if p, ok := req.GetPeer(); ok {
if scope, ok := r.notifyScopeFromInput(userID, p); ok && scope.Kind == domain.NotifyScopePeer {
fp := scope.Peer
filterPeer = &fp
}
}
}
updates := make([]tg.UpdateClass, 0, len(exceptions))
userIDs := make([]int64, 0)
channelIDs := make([]int64, 0)
for _, ex := range exceptions {
if filterPeer != nil && ex.Peer != *filterPeer {
continue
}
if !notifyExceptionQualifies(ex.Settings, compareStories) {
continue
}
scope := domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: ex.Peer, TopicID: ex.TopicID}
s := ex.Settings
updates = append(updates, &tg.UpdateNotifySettings{
Peer: tgNotifyPeer(scope),
NotifySettings: *tgPeerNotifySettings(&s),
})
switch ex.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, ex.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, ex.Peer.ID)
}
}
if len(updates) == 0 {
return empty, nil
}
return &tg.Updates{
Updates: updates,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
Chats: r.tgChatsForChannelIDs(ctx, userID, channelIDs),
Date: int(r.clock.Now().Unix()),
}, nil
}
// notifyExceptionQualifies 判定一条异常是否纳入 getNotifyExceptions 结果。
// 自定义铃声未建模故 compare_sound 无额外效果。
func notifyExceptionQualifies(s domain.PeerNotifySettings, compareStories bool) bool {
if s.MuteUntil != nil || s.Silent != nil || s.ShowPreviews != nil {
return true
}
if compareStories && (s.StoriesMuted != nil || s.StoriesHideSender != nil) {
return true
}
return false
}
// tgChatsForChannelIDs 把一组 channel id 解析为 tg.Chat无权限/已失效者跳过)。
// perf一次批量 GetChannels而非逐个 GetChannelN+1
func (r *Router) tgChatsForChannelIDs(ctx context.Context, viewerUserID int64, channelIDs []int64) []tg.ChatClass {
out := make([]tg.ChatClass, 0, len(channelIDs))
if r.deps.Channels == nil || len(channelIDs) == 0 {
return out
}
views, err := r.deps.Channels.GetChannels(ctx, viewerUserID, channelIDs)
if err != nil {
return out
}
for _, view := range views {
out = append(out, tgChannelChatForView(viewerUserID, view))
}
return out
}
// tgPeerNotifySettings 把存储的 per-peer 设置叠加在默认通知设置上:未设置的字段
// 保留默认(声音=default、show_previews=true 等已设置字段覆盖。nil=纯默认,
// 与历史 tdesktop.NotifySettings() 行为一致。
func tgPeerNotifySettings(s *domain.PeerNotifySettings) *tg.PeerNotifySettings {
out := tdesktop.NotifySettings()
if s == nil {
return out
}
if s.ShowPreviews != nil {
out.SetShowPreviews(*s.ShowPreviews)
}
if s.Silent != nil {
out.SetSilent(*s.Silent)
}
if s.MuteUntil != nil {
out.SetMuteUntil(*s.MuteUntil)
}
if s.StoriesMuted != nil {
out.SetStoriesMuted(*s.StoriesMuted)
}
if s.StoriesHideSender != nil {
out.SetStoriesHideSender(*s.StoriesHideSender)
}
return out
}
func domainPeerNotifySettings(in tg.InputPeerNotifySettings) domain.PeerNotifySettings {
out := domain.PeerNotifySettings{}
if v, ok := in.GetShowPreviews(); ok {
out.ShowPreviews = &v
}
if v, ok := in.GetSilent(); ok {
out.Silent = &v
}
if v, ok := in.GetMuteUntil(); ok {
out.MuteUntil = &v
}
if v, ok := in.GetStoriesMuted(); ok {
out.StoriesMuted = &v
}
if v, ok := in.GetStoriesHideSender(); ok {
out.StoriesHideSender = &v
}
return out
}
func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
switch scope.Kind {
case domain.NotifyScopeUsers:
return &tg.NotifyUsers{}
case domain.NotifyScopeChats:
return &tg.NotifyChats{}
case domain.NotifyScopeBroadcasts:
return &tg.NotifyBroadcasts{}
case domain.NotifyScopePeer:
peer := tgPeer(scope.Peer)
if scope.TopicID != 0 {
return &tg.NotifyForumTopic{Peer: peer, TopMsgID: scope.TopicID}
}
return &tg.NotifyPeer{Peer: peer}
default:
return &tg.NotifyUsers{}
}
}
// withDialogNotifySettings 装配 dialog 列表的 per-peer 通知设置,让静音状态在列表正确
// 显示且跨重启恢复。perf从 per-user notify 缓存读取(命中即 0 PG而非每次 getDialogs
// 都查 notify_settings——绝大多数用户没有任何自定义静音缓存命中后零数据库开销。
func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList {
if len(list.Dialogs) == 0 {
return list
}
settings := r.userNotifySettings(ctx, viewerUserID)
if len(settings) == 0 {
return list
}
for i := range list.Dialogs {
if s, ok := settings[list.Dialogs[i].Peer]; ok {
sc := s.Clone()
list.Dialogs[i].NotifySettings = &sc
}
}
return list
}
// applyNotifySettingsToUserFull 在缓存后 overlay userFull 的 notify_settings避免
// 投影缓存使 notify 状态陈旧。perf从 per-user notify 缓存读取,不再每次 getFullUser
// 单 peer 查 PG。
func (r *Router) applyNotifySettingsToUserFull(ctx context.Context, viewerUserID, ownerUserID int64, full *tg.UserFull) {
settings := r.userNotifySettings(ctx, viewerUserID)
s, ok := settings[domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}]
if !ok || s.IsZero() {
return
}
full.NotifySettings = *tgPeerNotifySettings(&s)
}
// applyNotifySettingsToChannelFull 在缓存后 overlay channelFull 的 notify_settings。
func (r *Router) applyNotifySettingsToChannelFull(ctx context.Context, viewerUserID, channelID int64, full *tg.ChannelFull) {
settings := r.userNotifySettings(ctx, viewerUserID)
s, ok := settings[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}]
if !ok || s.IsZero() {
return
}
full.NotifySettings = *tgPeerNotifySettings(&s)
}

View file

@ -0,0 +1,289 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func notifyRouter(t *testing.T) (*Router, *captureSessions) {
t.Helper()
passwordStore := memory.NewPasswordStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Account: appaccount.NewService(passwordStore, appaccount.WithNotifySettings(passwordStore)),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
return r, sessions
}
// TestNotifySettingsRoundTripAndDialogProjection 回归get/update/reset NotifySettings
// 此前是回显 stub不持久化mute 重启即丢、dialog 列表不反映静音。本测试验证
// per-peer 持久化往返 + dialog 列表投影出 mute + updateNotifySettings 推送 + reset。
func TestNotifySettingsRoundTripAndDialogProjection(t *testing.T) {
r, sessions := notifyRouter(t)
const viewer = int64(1000000001)
const peerID = int64(555)
ctx := WithUserID(context.Background(), viewer)
peerInput := &tg.InputNotifyPeer{Peer: &tg.InputPeerUser{UserID: peerID}}
// 默认:未配置 → mute_until=0不静音
def, err := r.onAccountGetNotifySettings(ctx, peerInput)
if err != nil {
t.Fatalf("get default: %v", err)
}
if mu, _ := def.GetMuteUntil(); mu != 0 {
t.Fatalf("default mute_until = %d, want 0", mu)
}
// mute 该 peer。
in := tg.InputPeerNotifySettings{}
in.SetMuteUntil(2000000000)
in.SetSilent(true)
if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: peerInput, Settings: in}); err != nil || !ok {
t.Fatalf("update notify = ok %v err %v", ok, err)
}
// 推送 updateNotifySettings。
snap := sessions.snapshot()
updates, ok := snap.message.(*tg.Updates)
if !ok || len(updates.Updates) == 0 {
t.Fatalf("pushed message = %#v, want *tg.Updates with updates", snap.message)
}
upd, ok := updates.Updates[0].(*tg.UpdateNotifySettings)
if !ok {
t.Fatalf("pushed update = %T, want *tg.UpdateNotifySettings", updates.Updates[0])
}
if np, ok := upd.Peer.(*tg.NotifyPeer); !ok {
t.Fatalf("pushed notify peer = %T, want *tg.NotifyPeer", upd.Peer)
} else if pu, ok := np.Peer.(*tg.PeerUser); !ok || pu.UserID != peerID {
t.Fatalf("pushed notify peer = %#v, want user %d", np.Peer, peerID)
}
// get 读回 mute。
got, err := r.onAccountGetNotifySettings(ctx, peerInput)
if err != nil {
t.Fatalf("get after mute: %v", err)
}
if mu, _ := got.GetMuteUntil(); mu != 2000000000 {
t.Fatalf("mute_until after mute = %d, want 2000000000", mu)
}
if silent, ok := got.GetSilent(); !ok || !silent {
t.Fatalf("silent after mute = %v ok %v, want true", silent, ok)
}
// dialog 列表投影出 mute跨重启恢复的关键
list := domain.DialogList{Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, TopMessage: 1},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 999}, TopMessage: 1}, // 未静音对照
}}
out, ok := r.tgMessagesDialogs(ctx, viewer, list).(*tg.MessagesDialogs)
if !ok {
t.Fatalf("dialogs projection type = %T", r.tgMessagesDialogs(ctx, viewer, list))
}
muted := dialogByPeerUser(t, out.Dialogs, peerID)
if mu, _ := muted.NotifySettings.GetMuteUntil(); mu != 2000000000 {
t.Fatalf("dialog mute_until = %d, want 2000000000列表未反映静音", mu)
}
unmuted := dialogByPeerUser(t, out.Dialogs, 999)
if mu, _ := unmuted.NotifySettings.GetMuteUntil(); mu != 0 {
t.Fatalf("unmuted dialog mute_until = %d, want 0", mu)
}
// reset → 回默认。
if ok, err := r.onAccountResetNotifySettings(ctx); err != nil || !ok {
t.Fatalf("reset = ok %v err %v", ok, err)
}
after, err := r.onAccountGetNotifySettings(ctx, peerInput)
if err != nil {
t.Fatalf("get after reset: %v", err)
}
if mu, _ := after.GetMuteUntil(); mu != 0 {
t.Fatalf("mute_until after reset = %d, want 0", mu)
}
}
// TestNotifySettingsCategoryDefaultScope 验证 inputNotifyUsers 等类别默认作用域独立持久化。
func TestNotifySettingsCategoryDefaultScope(t *testing.T) {
r, _ := notifyRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
in := tg.InputPeerNotifySettings{}
in.SetMuteUntil(123456)
if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}, Settings: in}); err != nil || !ok {
t.Fatalf("update users-default = ok %v err %v", ok, err)
}
// users 默认有值chats 默认仍为默认(作用域隔离)。
usersGot, err := r.onAccountGetNotifySettings(ctx, &tg.InputNotifyUsers{})
if err != nil {
t.Fatalf("get users-default: %v", err)
}
if mu, _ := usersGot.GetMuteUntil(); mu != 123456 {
t.Fatalf("users-default mute_until = %d, want 123456", mu)
}
chatsGot, err := r.onAccountGetNotifySettings(ctx, &tg.InputNotifyChats{})
if err != nil {
t.Fatalf("get chats-default: %v", err)
}
if mu, _ := chatsGot.GetMuteUntil(); mu != 0 {
t.Fatalf("chats-default mute_until = %d, want 0作用域应隔离", mu)
}
}
// TestGetNotifyExceptions 验证 getNotifyExceptions 列出 per-peer 非默认设置:
// mute 多个 peer → 出现在异常列表unmute → 退出compare_stories 过滤 story-only。
func TestGetNotifyExceptions(t *testing.T) {
r, _ := notifyRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
mute := func(peer tg.InputPeerClass, set func(*tg.InputPeerNotifySettings)) {
in := tg.InputPeerNotifySettings{}
set(&in)
if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyPeer{Peer: peer}, Settings: in}); err != nil || !ok {
t.Fatalf("update notify = ok %v err %v", ok, err)
}
}
// mute 一个 user + 一个 channel。
mute(&tg.InputPeerUser{UserID: 555}, func(in *tg.InputPeerNotifySettings) { in.SetMuteUntil(2000000000) })
mute(&tg.InputPeerChannel{ChannelID: 777}, func(in *tg.InputPeerNotifySettings) { in.SetSilent(true) })
ex := notifyExceptions(t, r, ctx, &tg.AccountGetNotifyExceptionsRequest{})
if len(ex) != 2 {
t.Fatalf("exceptions = %d, want 2", len(ex))
}
if !exceptionsHaveUser(ex, 555) || !exceptionsHaveChannel(ex, 777) {
t.Fatalf("exceptions missing expected peers: %#v", ex)
}
// unmute user 555发空设置→清空→ 退出异常列表。
mute(&tg.InputPeerUser{UserID: 555}, func(in *tg.InputPeerNotifySettings) {})
ex = notifyExceptions(t, r, ctx, &tg.AccountGetNotifyExceptionsRequest{})
if len(ex) != 1 || !exceptionsHaveChannel(ex, 777) {
t.Fatalf("after unmute exceptions = %#v, want only channel 777", ex)
}
// story-only 异常默认不计入compare_stories 计入。
mute(&tg.InputPeerUser{UserID: 888}, func(in *tg.InputPeerNotifySettings) { in.SetStoriesMuted(true) })
if got := notifyExceptions(t, r, ctx, &tg.AccountGetNotifyExceptionsRequest{}); len(got) != 1 {
t.Fatalf("default exceptions = %d, want 1 (story-only excluded)", len(got))
}
withStories := &tg.AccountGetNotifyExceptionsRequest{}
withStories.CompareStories = true
if got := notifyExceptions(t, r, ctx, withStories); len(got) != 2 || !exceptionsHaveUser(got, 888) {
t.Fatalf("compare_stories exceptions = %#v, want story-only 888 included", got)
}
}
func notifyExceptions(t *testing.T, r *Router, ctx context.Context, req *tg.AccountGetNotifyExceptionsRequest) []*tg.UpdateNotifySettings {
t.Helper()
out, err := r.onAccountGetNotifyExceptions(ctx, req)
if err != nil {
t.Fatalf("getNotifyExceptions: %v", err)
}
upd, ok := out.(*tg.Updates)
if !ok {
t.Fatalf("response = %T, want *tg.Updates", out)
}
res := make([]*tg.UpdateNotifySettings, 0, len(upd.Updates))
for _, u := range upd.Updates {
if uns, ok := u.(*tg.UpdateNotifySettings); ok {
res = append(res, uns)
}
}
return res
}
func exceptionsHaveUser(ex []*tg.UpdateNotifySettings, userID int64) bool {
for _, e := range ex {
if np, ok := e.Peer.(*tg.NotifyPeer); ok {
if pu, ok := np.Peer.(*tg.PeerUser); ok && pu.UserID == userID {
return true
}
}
}
return false
}
func exceptionsHaveChannel(ex []*tg.UpdateNotifySettings, channelID int64) bool {
for _, e := range ex {
if np, ok := e.Peer.(*tg.NotifyPeer); ok {
if pc, ok := np.Peer.(*tg.PeerChannel); ok && pc.ChannelID == channelID {
return true
}
}
}
return false
}
// countingNotifyService 包 *appaccount.Service 计数 AllPeerNotifySettings 调用,
// 验证 per-user notify 缓存短路了热路径查询。
type countingNotifyService struct {
*appaccount.Service
allCalls int
}
func (s *countingNotifyService) AllPeerNotifySettings(ctx context.Context, userID int64) (map[domain.Peer]domain.PeerNotifySettings, error) {
s.allCalls++
return s.Service.AllPeerNotifySettings(ctx, userID)
}
// TestNotifySettingsDialogProjectionCached 回归 P2-1dialog 投影从 per-user notify
// 缓存读取——重复 getDialogs 只加载一次(命中 0 PGupdate/reset 失效后重载。
func TestNotifySettingsDialogProjectionCached(t *testing.T) {
passwordStore := memory.NewPasswordStore()
svc := &countingNotifyService{Service: appaccount.NewService(passwordStore, appaccount.WithNotifySettings(passwordStore))}
r := New(Config{}, Deps{Account: svc, Sessions: &captureSessions{}}, zaptest.NewLogger(t), clock.System)
const viewer = int64(1000000001)
ctx := WithUserID(context.Background(), viewer)
in := tg.InputPeerNotifySettings{}
in.SetMuteUntil(2000000000)
if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyPeer{Peer: &tg.InputPeerUser{UserID: 555}}, Settings: in}); err != nil || !ok {
t.Fatalf("update notify: ok %v err %v", ok, err)
}
list := domain.DialogList{Dialogs: []domain.Dialog{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 555}, TopMessage: 1}}}
for i := 0; i < 3; i++ {
out := r.tgMessagesDialogs(ctx, viewer, list).(*tg.MessagesDialogs)
dlg := dialogByPeerUser(t, out.Dialogs, 555)
if mu, _ := dlg.NotifySettings.GetMuteUntil(); mu != 2000000000 {
t.Fatalf("iter %d dialog mute = %d, want 2000000000", i, mu)
}
}
if svc.allCalls != 1 {
t.Fatalf("AllPeerNotifySettings calls = %d, want 1 (后两次 getDialogs 应命中缓存)", svc.allCalls)
}
// update 失效缓存 → 下次 getDialogs 重载。
in2 := tg.InputPeerNotifySettings{}
in2.SetMuteUntil(2100000000)
if _, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyPeer{Peer: &tg.InputPeerUser{UserID: 555}}, Settings: in2}); err != nil {
t.Fatalf("re-update notify: %v", err)
}
r.tgMessagesDialogs(ctx, viewer, list)
if svc.allCalls != 2 {
t.Fatalf("after invalidation calls = %d, want 2 (缓存失效应重载)", svc.allCalls)
}
}
func dialogByPeerUser(t *testing.T, dialogs []tg.DialogClass, userID int64) *tg.Dialog {
t.Helper()
for _, d := range dialogs {
dlg, ok := d.(*tg.Dialog)
if !ok {
continue
}
if pu, ok := dlg.Peer.(*tg.PeerUser); ok && pu.UserID == userID {
return dlg
}
}
t.Fatalf("dialog for user %d not found", userID)
return nil
}

View file

@ -0,0 +1,120 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestAccountGetDefaultProfilePhotoEmojisUsesSeededEmojiSets(t *testing.T) {
files := &fakeFiles{sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindEmoji: {
{DocumentIDs: []int64{1001, 0, 1002, 1001}},
{DocumentIDs: []int64{1003}},
},
domain.StickerSetKindSystem: {
{SystemKey: "animated_emoji", DocumentIDs: []int64{2001}},
},
}}
r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System)
got, err := r.onAccountGetDefaultProfilePhotoEmojis(context.Background(), 0)
if err != nil {
t.Fatalf("get default profile photo emojis: %v", err)
}
list, ok := got.(*tg.EmojiList)
if !ok {
t.Fatalf("default profile photo emojis = %T, want *tg.EmojiList", got)
}
if len(list.DocumentID) != 3 || list.DocumentID[0] != 1001 || list.DocumentID[1] != 1002 || list.DocumentID[2] != 1003 {
t.Fatalf("document ids = %v, want deduped seeded emoji ids", list.DocumentID)
}
if list.Hash == 0 {
t.Fatal("emoji list hash = 0, want stable non-zero hash")
}
cached, err := r.onAccountGetDefaultProfilePhotoEmojis(context.Background(), list.Hash)
if err != nil {
t.Fatalf("get cached default profile photo emojis: %v", err)
}
if _, ok := cached.(*tg.EmojiListNotModified); !ok {
t.Fatalf("cached default profile photo emojis = %T, want notModified", cached)
}
}
func TestAccountGetDefaultProfilePhotoEmojisFallsBackToSystemAnimatedEmoji(t *testing.T) {
files := &fakeFiles{sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindSystem: {
{SystemKey: "dice:🎲", DocumentIDs: []int64{3001}},
{SystemKey: "animated_emoji", DocumentIDs: []int64{4001, 4002, 4001}},
{SystemKey: "emoji_generic_animations", DocumentIDs: []int64{5001}},
},
}}
r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System)
got, err := r.onAccountGetDefaultProfilePhotoEmojis(context.Background(), 0)
if err != nil {
t.Fatalf("get default profile photo emojis: %v", err)
}
list, ok := got.(*tg.EmojiList)
if !ok {
t.Fatalf("default profile photo emojis = %T, want *tg.EmojiList", got)
}
if len(list.DocumentID) != 2 || list.DocumentID[0] != 4001 || list.DocumentID[1] != 4002 {
t.Fatalf("document ids = %v, want animated_emoji fallback only", list.DocumentID)
}
}
func TestAccountGetDefaultBackgroundEmojisUsesStatusPack(t *testing.T) {
files := &fakeFiles{sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindEmoji: {
{ShortName: "OtherPack", DocumentIDs: []int64{9001}},
{ShortName: "StatusPack", DocumentIDs: []int64{1001, 0, 1002, 1001}},
},
}}
r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System)
got, err := r.onAccountGetDefaultBackgroundEmojis(context.Background(), 0)
if err != nil {
t.Fatalf("get default background emojis: %v", err)
}
list, ok := got.(*tg.EmojiList)
if !ok {
t.Fatalf("default background emojis = %T, want *tg.EmojiList", got)
}
if len(list.DocumentID) != 2 || list.DocumentID[0] != 1001 || list.DocumentID[1] != 1002 {
t.Fatalf("document ids = %v, want deduped StatusPack ids", list.DocumentID)
}
if list.Hash == 0 {
t.Fatal("emoji list hash = 0, want stable non-zero hash")
}
cached, err := r.onAccountGetDefaultBackgroundEmojis(context.Background(), list.Hash)
if err != nil {
t.Fatalf("get cached default background emojis: %v", err)
}
if _, ok := cached.(*tg.EmojiListNotModified); !ok {
t.Fatalf("cached default background emojis = %T, want notModified", cached)
}
}
func TestAccountGetDefaultBackgroundEmojisFallsBackWhenStatusPackMissing(t *testing.T) {
r := New(Config{}, Deps{Files: &fakeFiles{}}, zaptest.NewLogger(t), clock.System)
got, err := r.onAccountGetDefaultBackgroundEmojis(context.Background(), 0)
if err != nil {
t.Fatalf("get default background emojis without StatusPack: %v", err)
}
list, ok := got.(*tg.EmojiList)
if !ok {
t.Fatalf("fallback default background emojis = %T, want *tg.EmojiList", got)
}
if list.Hash != 0 || len(list.DocumentID) != 0 {
t.Fatalf("fallback list = hash %d ids %v, want empty compat stub", list.Hash, list.DocumentID)
}
}

View file

@ -0,0 +1,57 @@
package rpc
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
accountSettingsCacheMaxEntries = 4096
// accountSettingsCacheTTL 兜底跨实例失效;同实例 Set 即时失效。设置页连续调
// getGlobalPrivacy/getAccountTTL/getContentSettings/getContactSignUp 时只查一次 PG。
accountSettingsCacheTTL = 60 * time.Second
)
// accountSettingsCache 缓存 userID→AccountSettings避免设置页 4 个 get handler 各查
// 一次同一行N+1。AccountSettings 全值类型,无需深拷贝。
type accountSettingsCache struct {
cache *readmodelcache.Cache[int64, domain.AccountSettings]
}
func newAccountSettingsCache(now func() time.Time) *accountSettingsCache {
return &accountSettingsCache{
cache: readmodelcache.New[int64, domain.AccountSettings](readmodelcache.Config[int64, domain.AccountSettings]{
MaxEntries: accountSettingsCacheMaxEntries,
TTL: accountSettingsCacheTTL,
Now: now,
}),
}
}
func (c *accountSettingsCache) getOrLoad(ctx context.Context, userID int64, load func() (domain.AccountSettings, error)) (domain.AccountSettings, error) {
if c == nil || userID == 0 {
return load()
}
return c.cache.GetOrLoad(ctx, userID, load)
}
func (c *accountSettingsCache) Delete(userID int64) {
if c == nil || userID == 0 {
return
}
c.cache.Invalidate(userID)
}
// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
svc, ok := r.accountSettingsSvc()
if !ok {
return domain.DefaultAccountSettings(), nil
}
return r.accountSettings.getOrLoad(ctx, userID, func() (domain.AccountSettings, error) {
return svc.GetAccountSettings(ctx, userID)
})
}

View file

@ -0,0 +1,138 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// accountSettingsRouter 装配一个接通账号设置持久化(内存)的 Router。
func accountSettingsRouter(t *testing.T) *Router {
t.Helper()
passwordStore := memory.NewPasswordStore()
return New(Config{}, Deps{
Account: appaccount.NewService(passwordStore, appaccount.WithAccountSettings(passwordStore)),
}, zaptest.NewLogger(t), clock.System)
}
// TestAccountSettingsRoundTrip 回归globalPrivacy/accountTTL/contentSettings/
// contactSignUpNotification 此前是硬编码回显 stubset 不持久化)。本测试验证
// set→get 真往返:写入后读回与写入一致。
func TestAccountSettingsRoundTrip(t *testing.T) {
r := accountSettingsRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
// 默认未持久化globalPrivacy 全关、TTL 365、sensitive 关但可切换、注册通知不静音。
gp, err := r.onAccountGetGlobalPrivacySettings(ctx)
if err != nil {
t.Fatalf("get global privacy: %v", err)
}
if gp.ArchiveAndMuteNewNoncontactPeers || gp.HideReadMarks {
t.Fatalf("default global privacy must be all-off, got %+v", gp)
}
if ttl, err := r.onAccountGetAccountTTL(ctx); err != nil || ttl.Days != domain.DefaultAccountTTLDays {
t.Fatalf("default ttl = %v err %v, want %d", ttl, err, domain.DefaultAccountTTLDays)
}
cs, err := r.onAccountGetContentSettings(ctx)
if err != nil || cs.SensitiveEnabled || !cs.SensitiveCanChange {
t.Fatalf("default content settings = %+v err %v, want sensitive off + can_change on", cs, err)
}
if silent, err := r.onAccountGetContactSignUpNotification(ctx); err != nil || silent {
t.Fatalf("default contact signup silent = %v err %v, want false", silent, err)
}
// 写 globalPrivacy含 paid stars 可选字段)→ 读回一致。
in := tg.GlobalPrivacySettings{
ArchiveAndMuteNewNoncontactPeers: true,
HideReadMarks: true,
NewNoncontactPeersRequirePremium: true,
}
in.SetNoncontactPeersPaidStars(50)
saved, err := r.onAccountSetGlobalPrivacySettings(ctx, in)
if err != nil {
t.Fatalf("set global privacy: %v", err)
}
assertGlobalPrivacy(t, saved, in)
got, err := r.onAccountGetGlobalPrivacySettings(ctx)
if err != nil {
t.Fatalf("re-get global privacy: %v", err)
}
assertGlobalPrivacy(t, got, in)
// 写 TTL → 读回。
if _, err := r.onAccountSetAccountTTL(ctx, tg.AccountDaysTTL{Days: 30}); err != nil {
t.Fatalf("set ttl: %v", err)
}
if ttl, err := r.onAccountGetAccountTTL(ctx); err != nil || ttl.Days != 30 {
t.Fatalf("ttl after set = %v err %v, want 30", ttl, err)
}
// TTL=0 非法。
if ok, err := r.onAccountSetAccountTTL(ctx, tg.AccountDaysTTL{Days: 0}); ok || !tgerr.Is(err, "TTL_DAYS_INVALID") {
t.Fatalf("ttl=0 = ok %v err %v, want TTL_DAYS_INVALID", ok, err)
}
// 写 sensitive content → 读回。
if ok, err := r.onAccountSetContentSettings(ctx, &tg.AccountSetContentSettingsRequest{SensitiveEnabled: true}); err != nil || !ok {
t.Fatalf("set content settings: ok %v err %v", ok, err)
}
if cs, err := r.onAccountGetContentSettings(ctx); err != nil || !cs.SensitiveEnabled || !cs.SensitiveCanChange {
t.Fatalf("content settings after set = %+v err %v, want sensitive on + can_change on", cs, err)
}
// 写 contact signup silent → 读回。
if ok, err := r.onAccountSetContactSignUpNotification(ctx, true); err != nil || !ok {
t.Fatalf("set contact signup silent: ok %v err %v", ok, err)
}
if silent, err := r.onAccountGetContactSignUpNotification(ctx); err != nil || !silent {
t.Fatalf("contact signup silent after set = %v err %v, want true", silent, err)
}
// 互不干扰:写完 contactSignUp 后 globalPrivacy 仍是之前写入的值。
if got, err := r.onAccountGetGlobalPrivacySettings(ctx); err != nil || !got.ArchiveAndMuteNewNoncontactPeers {
t.Fatalf("global privacy must survive other settings writes, got %+v err %v", got, err)
}
}
// TestAccountSettingsNotWiredFallsBack 验证未接通持久化服务时各 handler 回落默认、不报错。
func TestAccountSettingsNotWiredFallsBack(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)
if _, err := r.onAccountGetGlobalPrivacySettings(ctx); err != nil {
t.Fatalf("get global privacy (unwired): %v", err)
}
if ttl, err := r.onAccountGetAccountTTL(ctx); err != nil || ttl.Days != domain.DefaultAccountTTLDays {
t.Fatalf("get ttl (unwired) = %v err %v", ttl, err)
}
if ok, err := r.onAccountSetGlobalPrivacySettings(ctx, tg.GlobalPrivacySettings{HideReadMarks: true}); err != nil || ok == nil {
t.Fatalf("set global privacy (unwired) must echo, ok %v err %v", ok, err)
}
if ok, err := r.onAccountSetContentSettings(ctx, &tg.AccountSetContentSettingsRequest{SensitiveEnabled: true}); err != nil || !ok {
t.Fatalf("set content settings (unwired): ok %v err %v", ok, err)
}
}
func assertGlobalPrivacy(t *testing.T, got *tg.GlobalPrivacySettings, want tg.GlobalPrivacySettings) {
t.Helper()
if got.ArchiveAndMuteNewNoncontactPeers != want.ArchiveAndMuteNewNoncontactPeers ||
got.KeepArchivedUnmuted != want.KeepArchivedUnmuted ||
got.KeepArchivedFolders != want.KeepArchivedFolders ||
got.HideReadMarks != want.HideReadMarks ||
got.NewNoncontactPeersRequirePremium != want.NewNoncontactPeersRequirePremium ||
got.DisplayGiftsButton != want.DisplayGiftsButton {
t.Fatalf("global privacy bools = %+v, want %+v", got, want)
}
wantStars, _ := want.GetNoncontactPeersPaidStars()
gotStars, _ := got.GetNoncontactPeersPaidStars()
if gotStars != wantStars {
t.Fatalf("noncontact paid stars = %d, want %d", gotStars, wantStars)
}
}

View file

@ -0,0 +1,265 @@
package rpc
import (
"context"
"strings"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// onAccountGetThemes 返回内置默认主题(emoji 预览条用)+ 当前用户「创建 安装」的
// 自定义云主题(跨设备同步:换设备登录同账号即可看到自建主题)。哈希按整体主题集合计算,
// 集合变化即驱动客户端重取。
//
// 按 req.Format 分流(这正是官方服务端的做法):
// - Android(format="android")把 account.getThemes 里 is_default 的主题当作 emoji
// 预览条素材,直接从 settings 渲染,不需要 document(见 DrKLO
// Theme.loadRemoteThemes / MediaDataController.generateEmojiPreviewThemes)。
// - TDesktop(format="tdesktop")把 account.getThemes 的结果当作「云主题」网格,点击
// 应用时要求主题携带 .tdesktop-theme document,否则弹 lng_theme_no_desktop
// 「doesn't include a version for Telegram Desktop」(见 data_cloud_themes.cpp
// CloudThemes::showPreview:documentId==0 且非 creator → 报错)。
//
// 我们合成的 emoji 聊天主题只有 settings、没有 document,只适合 Android 的预览条。因此对
// tdesktop 不下发这些默认主题,只返回用户自建/安装(带 document)的云主题——对全新账号即
// 空网格,与官方 TDesktop 行为一致;TDesktop 的 emoji 聊天主题走 account.getChatThemes
// 的「单聊主题选择器」(基于 settings,无需 document)。
func (r *Router) onAccountGetThemes(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) {
var themes []tg.Theme
format := ""
if req != nil {
format = req.GetFormat()
}
if !strings.EqualFold(format, "tdesktop") {
themes = tdesktop.DefaultThemeList()
}
if r.deps.Themes != nil {
if userID, _, err := r.currentUserID(ctx); err == nil && userID != 0 {
if userThemes, err := r.deps.Themes.ListForUser(ctx, userID); err == nil {
for _, t := range userThemes {
themes = append(themes, *r.tgTheme(ctx, t, userID))
}
}
}
}
hash := themesListHash(themes)
if req != nil && req.GetHash() == hash {
return &tg.AccountThemesNotModified{}, nil
}
return &tg.AccountThemes{Hash: hash, Themes: themes}, nil
}
// onAccountUploadTheme 把客户端上传的 .attheme 文件落成可下载的 Document 并返回。
// 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。
func (r *Router) onAccountUploadTheme(ctx context.Context, req *tg.AccountUploadThemeRequest) (tg.DocumentClass, error) {
if req == nil {
return nil, tgerr400("THEME_FILE_INVALID")
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Files == nil {
return nil, notImplementedErr()
}
if req.File == nil {
return nil, tgerr400("THEME_FILE_INVALID")
}
if !strings.HasPrefix(req.MimeType, "application/x-tgtheme-") {
return nil, tgerr400("THEME_MIME_INVALID")
}
ref, ok := uploadedFileRef(userID, req.File)
if !ok {
return nil, tgerr400("THEME_FILE_INVALID")
}
spec := domain.DocumentSpec{
MimeType: req.MimeType,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: req.FileName}},
}
if thumb, ok := req.GetThumb(); ok {
if tref, ok2 := uploadedFileRef(userID, thumb); ok2 {
spec.Thumb = &tref
}
}
doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec)
if err != nil {
return nil, mediaUploadErr(err)
}
return tgDocument(doc), nil
}
// onAccountCreateTheme 创建一份新的自定义云主题。空 slug 由服务端自动分配;返回的
// tg.Theme 设 creator=true,使客户端下次重传走 updateTheme 而非再次 create。
func (r *Router) onAccountCreateTheme(ctx context.Context, req *tg.AccountCreateThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
spec := domain.ThemeSpec{
CreatorUserID: userID,
Slug: req.Slug,
Title: req.Title,
}
if doc, ok := req.GetDocument(); ok {
if d, ok2 := doc.(*tg.InputDocument); ok2 {
spec.DocumentID = d.ID
}
}
if settings, ok := req.GetSettings(); ok {
spec.Settings = domainThemeSettingsFromInput(settings)
}
t, err := r.deps.Themes.Create(ctx, spec)
if err != nil {
return nil, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return r.tgTheme(ctx, t, userID), nil
}
// onAccountUpdateTheme 更新创建者自己的主题(部分字段)。
func (r *Router) onAccountUpdateTheme(ctx context.Context, req *tg.AccountUpdateThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
ref, ok := themeRefFromInput(req.Theme)
if !ok {
return nil, themeInvalidErr()
}
var upd domain.ThemeUpdate
if v, ok := req.GetSlug(); ok {
upd.Slug = &v
}
if v, ok := req.GetTitle(); ok {
upd.Title = &v
}
if doc, ok := req.GetDocument(); ok {
if d, ok2 := doc.(*tg.InputDocument); ok2 {
id := d.ID
upd.DocumentID = &id
}
}
if settings, ok := req.GetSettings(); ok {
s := domainThemeSettingsFromInput(settings)
upd.Settings = &s
}
t, err := r.deps.Themes.Update(ctx, userID, ref, upd)
if err != nil {
return nil, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return r.tgTheme(ctx, t, userID), nil
}
// onAccountSaveTheme 把主题加入/移出用户的已存列表。
func (r *Router) onAccountSaveTheme(ctx context.Context, req *tg.AccountSaveThemeRequest) (bool, error) {
if req == nil {
return false, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if userID == 0 {
return false, authKeyUnregisteredErr()
}
if r.deps.Themes == nil {
return false, notImplementedErr()
}
ref, ok := themeRefFromInput(req.Theme)
if !ok {
return false, themeInvalidErr()
}
if req.Unsave {
err = r.deps.Themes.Unsave(ctx, userID, ref)
} else {
err = r.deps.Themes.Save(ctx, userID, ref)
}
if err != nil {
return false, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
// onAccountInstallTheme 应用主题(计数 +1,加入已安装列表)。无 theme 引用时为
// 「安装本地/基础主题」的 no-op。
func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInstallThemeRequest) (bool, error) {
if req == nil {
return false, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
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 安装
}
ref, ok := themeRefFromInput(theme)
if !ok {
return false, themeInvalidErr()
}
if err := r.deps.Themes.Install(ctx, userID, ref, dark); err != nil {
return false, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
// onAccountGetTheme 按 id 或 slug(深链 telesrv.net/addtheme/<slug>)取主题。
func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
ref, ok := themeRefFromInput(req.Theme)
if !ok {
return nil, themeInvalidErr()
}
t, ok, err := r.deps.Themes.Get(ctx, ref)
if err != nil {
return nil, themeErr(err)
}
if !ok {
return nil, themeInvalidErr()
}
return r.tgTheme(ctx, t, userID), nil
}

View file

@ -0,0 +1,183 @@
package rpc
import (
"context"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// DrKLO 12.8.1(Layer 227)发出的 theme 方法构造器比 gotd v0.158 的 schema 更新:
// gotd 的 ServerDispatcher 按构造器 id 匹配,这些「新」id 匹配不上而落到 fallback。
// 字段语义与 gotd 请求结构相同(仅 createTheme/updateTheme 的 settings 在线为单个对象、
// gotd 为 Vector;getTheme 多一个被忽略的 document_id),故这里按 DrKLO 字段序手动解码、
// 复用现有 handler。uploadTheme(0x1c3db333)/saveTheme(0xf257106c) 构造器与 gotd 一致,
// 无需 compat。
const (
legacyCreateThemeID = 0x8432c21f
legacyUpdateThemeID = 0x5cb367d5
legacyInstallThemeID = 0x7ae43737
legacyGetThemeID = 0x8d9d742b
)
// tryLegacyThemeRPC 在 fallback 中尝试处理 DrKLO 的新版 theme 构造器。
// handled=false 表示不是这些构造器,调用方继续原 fallback 流程。
func (r *Router) tryLegacyThemeRPC(ctx context.Context, b *bin.Buffer) (enc bin.Encoder, handled bool, err error) {
id, perr := b.PeekID()
if perr != nil {
return nil, false, nil
}
switch id {
case legacyCreateThemeID:
enc, err = r.legacyCreateTheme(ctx, b)
case legacyUpdateThemeID:
enc, err = r.legacyUpdateTheme(ctx, b)
case legacyInstallThemeID:
enc, err = r.legacyInstallTheme(ctx, b)
case legacyGetThemeID:
enc, err = r.legacyGetTheme(ctx, b)
default:
return nil, false, nil
}
return enc, true, err
}
func boolEncoder(v bool) bin.Encoder {
if v {
return &tg.BoolTrue{}
}
return &tg.BoolFalse{}
}
func (r *Router) legacyCreateTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) {
if _, err := b.ID(); err != nil {
return nil, inputConstructorInvalidErr()
}
flags, err := b.Int32()
if err != nil {
return nil, inputConstructorInvalidErr()
}
slug, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
title, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
req := &tg.AccountCreateThemeRequest{Slug: slug, Title: title}
if flags&(1<<2) != 0 {
doc, err := tg.DecodeInputDocument(b)
if err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetDocument(doc)
}
if flags&(1<<3) != 0 {
var s tg.InputThemeSettings
if err := s.Decode(b); err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetSettings([]tg.InputThemeSettings{s})
}
return r.onAccountCreateTheme(ctx, req)
}
func (r *Router) legacyUpdateTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) {
if _, err := b.ID(); err != nil {
return nil, inputConstructorInvalidErr()
}
flags, err := b.Int32()
if err != nil {
return nil, inputConstructorInvalidErr()
}
format, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
theme, err := tg.DecodeInputTheme(b)
if err != nil {
return nil, inputConstructorInvalidErr()
}
req := &tg.AccountUpdateThemeRequest{Format: format, Theme: theme}
if flags&(1<<0) != 0 {
slug, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetSlug(slug)
}
if flags&(1<<1) != 0 {
title, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetTitle(title)
}
if flags&(1<<2) != 0 {
doc, err := tg.DecodeInputDocument(b)
if err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetDocument(doc)
}
if flags&(1<<3) != 0 {
var s tg.InputThemeSettings
if err := s.Decode(b); err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetSettings([]tg.InputThemeSettings{s})
}
return r.onAccountUpdateTheme(ctx, req)
}
func (r *Router) legacyInstallTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) {
if _, err := b.ID(); err != nil {
return nil, inputConstructorInvalidErr()
}
flags, err := b.Int32()
if err != nil {
return nil, inputConstructorInvalidErr()
}
req := &tg.AccountInstallThemeRequest{}
if flags&(1<<0) != 0 {
req.SetDark(true)
}
// bit1 同时门控 format 与 theme(DrKLO 布局)。
if flags&(1<<1) != 0 {
format, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
theme, err := tg.DecodeInputTheme(b)
if err != nil {
return nil, inputConstructorInvalidErr()
}
req.SetFormat(format)
req.SetTheme(theme)
}
ok, err := r.onAccountInstallTheme(ctx, req)
if err != nil {
return nil, err
}
return boolEncoder(ok), nil
}
func (r *Router) legacyGetTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) {
if _, err := b.ID(); err != nil {
return nil, inputConstructorInvalidErr()
}
format, err := b.String()
if err != nil {
return nil, inputConstructorInvalidErr()
}
theme, err := tg.DecodeInputTheme(b)
if err != nil {
return nil, inputConstructorInvalidErr()
}
if _, err := b.Long(); err != nil { // document_id:被服务端忽略
return nil, inputConstructorInvalidErr()
}
req := &tg.AccountGetThemeRequest{Format: format, Theme: theme}
return r.onAccountGetTheme(ctx, req)
}

View file

@ -0,0 +1,90 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// TestLegacyThemeWireDecode 验证按 DrKLO 12.8.1 的 theme 构造器(比 gotd schema 新)
// 手写解码后能正确复用现有 handler。直接构造 DrKLO 的 wire 字节喂给 fallback compat 层。
func TestLegacyThemeWireDecode(t *testing.T) {
const userID = 1000010
ctx := WithUserID(context.Background(), userID)
files := &fakeFiles{docs: map[int64]domain.Document{
777: {ID: 777, AccessHash: 7, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 4096},
}}
r := newThemeRouter(t, files)
// createTheme 0x8432c21f:flags(=4,document) + slug + title + InputDocument。
var cb bin.Buffer
cb.PutID(legacyCreateThemeID)
cb.PutInt32(1 << 2) // document present
cb.PutString("") // empty slug → auto
cb.PutString("Legacy Theme")
(&tg.InputDocument{ID: 777, AccessHash: 7}).Encode(&cb)
enc, handled, err := r.tryLegacyThemeRPC(ctx, &cb)
if !handled || err != nil {
t.Fatalf("createTheme legacy = handled %v err %v", handled, err)
}
th, ok := enc.(*tg.Theme)
if !ok {
t.Fatalf("createTheme legacy result = %T, want *tg.Theme", enc)
}
if th.Slug == "" || !th.GetCreator() {
t.Fatalf("created theme = slug %q creator %v, want auto slug + creator", th.Slug, th.GetCreator())
}
if doc, ok := th.GetDocument(); !ok {
t.Fatalf("created theme missing document")
} else if d, _ := doc.(*tg.Document); d == nil || d.ID != 777 {
t.Fatalf("created theme document = %#v, want id 777", doc)
}
mustEncodeTheme(t, th)
slug := th.Slug
// getTheme 0x8d9d742b:format + InputThemeSlug + document_id(被忽略)。
var gb bin.Buffer
gb.PutID(legacyGetThemeID)
gb.PutString("android")
(&tg.InputThemeSlug{Slug: slug}).Encode(&gb)
gb.PutLong(12345) // document_id ignored
enc, handled, err = r.tryLegacyThemeRPC(ctx, &gb)
if !handled || err != nil {
t.Fatalf("getTheme legacy = handled %v err %v", handled, err)
}
got, ok := enc.(*tg.Theme)
if !ok || got.Slug != slug {
t.Fatalf("getTheme legacy result = %#v, want theme slug %q", enc, slug)
}
if _, ok := got.GetDocument(); !ok {
t.Fatalf("getTheme by slug missing document → client ThemeNotSupported")
}
// installTheme 0x7ae43737:flags(dark@bit0 + bit1 gates format+theme)。
var ib bin.Buffer
ib.PutID(legacyInstallThemeID)
ib.PutInt32((1 << 0) | (1 << 1)) // dark + has format/theme
ib.PutString("android")
(&tg.InputThemeSlug{Slug: slug}).Encode(&ib)
enc, handled, err = r.tryLegacyThemeRPC(ctx, &ib)
if !handled || err != nil {
t.Fatalf("installTheme legacy = handled %v err %v", handled, err)
}
if _, ok := enc.(*tg.BoolTrue); !ok {
t.Fatalf("installTheme legacy result = %T, want *tg.BoolTrue", enc)
}
// 非 theme 构造器 → 不处理。
var ob bin.Buffer
ob.PutID(0x12345678)
if _, handled, _ := r.tryLegacyThemeRPC(ctx, &ob); handled {
t.Fatalf("unrelated ctor should not be handled")
}
}

View file

@ -0,0 +1,264 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
themesapp "telesrv/internal/app/themes"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newThemeRouter(t *testing.T, files *fakeFiles) *Router {
t.Helper()
return New(Config{}, Deps{
Files: files,
Themes: themesapp.NewService(memory.NewThemeStore()),
}, zaptest.NewLogger(t), clock.System)
}
func mustEncodeTheme(t *testing.T, th *tg.Theme) {
t.Helper()
var b bin.Buffer
if err := th.Encode(&b); err != nil {
t.Fatalf("theme encode failed (likely nil base_theme): %v", err)
}
}
// TestAccountUploadThemeReturnsDocument 验证 uploadTheme 把上传文件落成可下载 Document 返回,
// 且校验 mime 前缀。
func TestAccountUploadThemeReturnsDocument(t *testing.T) {
ctx := WithUserID(context.Background(), 1000001)
r := newThemeRouter(t, &fakeFiles{})
req := &tg.AccountUploadThemeRequest{FileName: "theme.attheme", MimeType: "application/x-tgtheme-android"}
req.File = &tg.InputFile{ID: 42, Parts: 1, Name: "theme.attheme"}
got, err := r.onAccountUploadTheme(ctx, req)
if err != nil {
t.Fatalf("uploadTheme err = %v", err)
}
doc, ok := got.(*tg.Document)
if !ok {
t.Fatalf("uploadTheme = %T, want *tg.Document", got)
}
if doc.ID == 0 {
t.Fatalf("uploaded document id = 0, want non-zero downloadable id")
}
// 非主题 mime 前缀拒绝。
bad := &tg.AccountUploadThemeRequest{FileName: "x", MimeType: "image/png"}
bad.File = &tg.InputFile{ID: 7, Parts: 1, Name: "x"}
if _, err := r.onAccountUploadTheme(ctx, bad); !tgerr.Is(err, "THEME_MIME_INVALID") {
t.Fatalf("bad mime err = %v, want THEME_MIME_INVALID", err)
}
}
// TestAccountCreateThemeFullFlow 验证 createTheme 自动分配 slug、设 creator=true、回填 document,
// getTheme 按 slug 取回带 document;saveTheme/installTheme 返回 true。
func TestAccountCreateThemeFullFlow(t *testing.T) {
const userID = 1000002
ctx := WithUserID(context.Background(), userID)
files := &fakeFiles{docs: map[int64]domain.Document{
555: {ID: 555, AccessHash: 5, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 1234},
}}
r := newThemeRouter(t, files)
create := &tg.AccountCreateThemeRequest{Slug: "", Title: "My Theme"}
create.SetDocument(&tg.InputDocument{ID: 555, AccessHash: 5})
th, err := r.onAccountCreateTheme(ctx, create)
if err != nil {
t.Fatalf("createTheme err = %v", err)
}
if th.ID == 0 || th.AccessHash == 0 {
t.Fatalf("created theme id/access_hash = %d/%d, want non-zero", th.ID, th.AccessHash)
}
if !th.GetCreator() {
t.Fatalf("created theme creator = false, want true (so client routes re-upload to updateTheme)")
}
if th.Slug == "" {
t.Fatalf("created theme slug empty, want auto-assigned slug")
}
if doc, ok := th.GetDocument(); !ok {
t.Fatalf("created theme has no document, want document (deep-link addtheme needs it)")
} else if d, ok := doc.(*tg.Document); !ok || d.ID != 555 {
t.Fatalf("created theme document = %#v, want *tg.Document id 555", doc)
}
mustEncodeTheme(t, th)
// getTheme by slug 取回,仍带 document。
get := &tg.AccountGetThemeRequest{Format: "android"}
get.Theme = &tg.InputThemeSlug{Slug: th.Slug}
got, err := r.onAccountGetTheme(ctx, get)
if err != nil {
t.Fatalf("getTheme by slug err = %v", err)
}
if got.ID != th.ID {
t.Fatalf("getTheme id = %d, want %d", got.ID, th.ID)
}
if _, ok := got.GetDocument(); !ok {
t.Fatalf("getTheme by slug missing document → client would show ThemeNotSupported")
}
mustEncodeTheme(t, got)
// saveTheme + installTheme by id+access_hash。
save := &tg.AccountSaveThemeRequest{Theme: &tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash}, Unsave: false}
if ok, err := r.onAccountSaveTheme(ctx, save); err != nil || !ok {
t.Fatalf("saveTheme = %v/%v, want true/nil", ok, err)
}
inst := &tg.AccountInstallThemeRequest{}
inst.SetTheme(&tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash})
inst.SetDark(true)
if ok, err := r.onAccountInstallTheme(ctx, inst); err != nil || !ok {
t.Fatalf("installTheme = %v/%v, want true/nil", ok, err)
}
// 未知主题引用 → THEME_INVALID。
bad := &tg.AccountGetThemeRequest{Format: "android", Theme: &tg.InputThemeSlug{Slug: "does-not-exist"}}
if _, err := r.onAccountGetTheme(ctx, bad); !tgerr.Is(err, "THEME_INVALID") {
t.Fatalf("getTheme unknown slug err = %v, want THEME_INVALID", err)
}
}
// TestAccountGetThemesIncludesUserThemes 验证 getThemes 跨设备同步:返回内置默认主题
// (is_default=true,emoji 预览条用)+ 当前用户创建的自定义主题(is_default=false,creator=true);
// hash 稳定→NotModified,集合变化→重取。
func TestAccountGetThemesIncludesUserThemes(t *testing.T) {
const userID = 1000004
ctx := WithUserID(context.Background(), userID)
files := &fakeFiles{docs: map[int64]domain.Document{
321: {ID: 321, AccessHash: 3, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 100},
}}
r := newThemeRouter(t, files)
// 初始:仅默认主题。
first, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "android", Hash: 0})
if err != nil {
t.Fatalf("getThemes initial err = %v", err)
}
base, ok := first.(*tg.AccountThemes)
if !ok {
t.Fatalf("getThemes initial = %T, want *tg.AccountThemes", first)
}
defaultCount := len(base.Themes)
if defaultCount == 0 {
t.Fatalf("getThemes returned no default themes")
}
// 同 hash 回传 → NotModified。
if again, _ := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Hash: base.Hash}); func() bool {
_, ok := again.(*tg.AccountThemesNotModified)
return !ok
}() {
t.Fatalf("getThemes with matching hash should be NotModified")
}
// 创建一个自定义主题。
create := &tg.AccountCreateThemeRequest{Title: "Synced"}
create.SetDocument(&tg.InputDocument{ID: 321, AccessHash: 3})
created, err := r.onAccountCreateTheme(ctx, create)
if err != nil {
t.Fatalf("createTheme err = %v", err)
}
// 现在 getThemes 必须包含该自定义主题,且 hash 变化。
second, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Hash: base.Hash})
if err != nil {
t.Fatalf("getThemes after create err = %v", err)
}
merged, ok := second.(*tg.AccountThemes)
if !ok {
t.Fatalf("getThemes after create = %T, want *tg.AccountThemes (hash changed)", second)
}
if len(merged.Themes) != defaultCount+1 {
t.Fatalf("getThemes themes = %d, want %d (defaults + 1 custom)", len(merged.Themes), defaultCount+1)
}
var foundCustom bool
for _, th := range merged.Themes {
if th.ID == created.ID {
foundCustom = true
if th.GetDefault() {
t.Fatalf("custom theme is_default = true, want false (must not pollute emoji strip)")
}
if !th.GetCreator() {
t.Fatalf("custom theme creator = false, want true for owner")
}
}
}
if !foundCustom {
t.Fatalf("getThemes did not include the user's created theme id %d", created.ID)
}
}
// TestAccountGetThemesTDesktopExcludesDocumentlessDefaults 验证:对 format="tdesktop"
// 不下发无 document 的合成 emoji 默认主题(否则 TDesktop 云主题网格点击会弹
// lng_theme_no_desktop),而 format="android" 仍下发它们供 emoji 预览条使用。
func TestAccountGetThemesTDesktopExcludesDocumentlessDefaults(t *testing.T) {
const userID = 1000007
ctx := WithUserID(context.Background(), userID)
r := newThemeRouter(t, &fakeFiles{})
android, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "android", Hash: 0})
if err != nil {
t.Fatalf("getThemes android err = %v", err)
}
a, ok := android.(*tg.AccountThemes)
if !ok || len(a.Themes) == 0 {
t.Fatalf("getThemes android = %#v, want non-empty default themes", android)
}
desktop, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "tdesktop", Hash: 0})
if err != nil {
t.Fatalf("getThemes tdesktop err = %v", err)
}
switch d := desktop.(type) {
case *tg.AccountThemes:
for _, th := range d.Themes {
if _, hasDoc := th.GetDocument(); !hasDoc {
t.Fatalf("tdesktop getThemes returned documentless theme id=%d emoticon=%q, want only document-backed cloud themes", th.ID, func() string { e, _ := th.GetEmoticon(); return e }())
}
}
case *tg.AccountThemesNotModified:
// 空集合(全新账号无自建主题)对应的稳定 hash 与 0 不同,首个 hash=0 不会命中,
// 故这里若返回 NotModified 说明哈希实现异常。
t.Fatalf("tdesktop getThemes with hash=0 = NotModified, want themes")
default:
t.Fatalf("tdesktop getThemes = %T, want *tg.AccountThemes", desktop)
}
}
// TestAccountCreateThemeAccentSettingsEncode 验证带 settings 的 accent 主题往返且 base_theme 非空可编码。
func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) {
ctx := WithUserID(context.Background(), 1000003)
r := newThemeRouter(t, &fakeFiles{})
settings := tg.InputThemeSettings{BaseTheme: &tg.BaseThemeDay{}, AccentColor: 0x3997d3}
settings.SetMessageColors([]int{0xd4f1ff, 0xb9e4ff})
wp := tg.WallPaperSettings{}
wp.SetBackgroundColor(0xd4f1ff)
wp.SetSecondBackgroundColor(0xb9e4ff)
settings.SetWallpaperSettings(wp)
settings.SetWallpaper(&tg.InputWallPaperNoFile{})
create := &tg.AccountCreateThemeRequest{Title: "Accent"}
create.SetSettings([]tg.InputThemeSettings{settings})
th, err := r.onAccountCreateTheme(ctx, create)
if err != nil {
t.Fatalf("createTheme accent err = %v", err)
}
out, ok := th.GetSettings()
if !ok || len(out) != 1 {
t.Fatalf("created accent theme settings = %#v ok=%v, want 1", out, ok)
}
if _, isDay := out[0].BaseTheme.(*tg.BaseThemeDay); !isDay {
t.Fatalf("settings[0].base_theme = %T, want BaseThemeDay", out[0].BaseTheme)
}
if out[0].AccentColor != 0x3997d3 {
t.Fatalf("accent color = %#x, want 0x3997d3", out[0].AccentColor)
}
mustEncodeTheme(t, th)
}

View file

@ -0,0 +1,33 @@
package rpc
import (
"context"
"telesrv/internal/domain"
)
// RevokeAuthorizationAuthKey is the domain-only hook used by the internal Admin API
// after the auth service has removed the durable authorization/auth_key rows.
func (r *Router) RevokeAuthorizationAuthKey(ctx context.Context, authKeyID [8]byte, userID int64) error {
if r == nil || authKeyID == ([8]byte{}) {
return nil
}
r.revokeAuthKeySessions(authKeyID)
if err := r.clearAuthKeyState(ctx, authKeyID); err != nil {
return err
}
if userID != 0 {
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(authKeyID), userID)
}
return nil
}
// NotifyChannelChanged is the domain-only hook used by the internal Admin API
// after a channel/supergroup base fact changed.
func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) error {
if r == nil || ch.ID == 0 {
return nil
}
r.channelStateMutationUpdates(ctx, ch.CreatorUserID, ch)
return nil
}

View file

@ -0,0 +1,284 @@
package rpc
import (
"context"
"strings"
"testing"
"go.uber.org/zap/zaptest"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
botsapp "telesrv/internal/app/bots"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestLegacyAndroidMessagesCreateChatCreatesMegagroupWithTTL(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550001071", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550001072", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
}, zaptest.NewLogger(t), clock.System)
const ttlPeriod = 7 * 24 * 60 * 60
var in bin.Buffer
in.PutID(0x0034a818)
if err := (&tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Android Group",
TTLPeriod: ttlPeriod,
}).EncodeBare(&in); err != nil {
t.Fatalf("encode legacy createChat body: %v", err)
}
enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch legacy createChat: %v", err)
}
invited, ok := enc.(*tg.MessagesInvitedUsers)
if !ok {
t.Fatalf("response = %T, want messages.invitedUsers", enc)
}
updates, ok := invited.Updates.(*tg.Updates)
if !ok || len(updates.Chats) < 2 {
t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates)
}
legacy, ok := updates.Chats[0].(*tg.Chat)
if !ok || !legacy.Deactivated {
t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0])
}
channel, ok := updates.Chats[1].(*tg.Channel)
if !ok || !channel.Megagroup || !channel.Creator {
t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1])
}
view, err := channelService.GetChannel(ctx, owner.ID, channel.ID)
if err != nil {
t.Fatalf("get created channel: %v", err)
}
if !view.Channel.Megagroup || view.Channel.Broadcast {
t.Fatalf("created channel = %+v, want megagroup only", view.Channel)
}
if view.Channel.TTLPeriod != ttlPeriod {
t.Fatalf("ttl_period = %d, want %d", view.Channel.TTLPeriod, ttlPeriod)
}
}
func TestLegacyAndroidAuthSignUpAllowedBeforeAuthorization(t *testing.T) {
authKeyID := [8]byte{0x31, 0xd3, 0x36, 0xc1, 0x7a, 0x1a, 0x33, 0x48}
auth := &captureAuthService{
signUpUser: domain.User{
ID: 1000004242,
AccessHash: 424242,
Phone: "15550004242",
FirstName: "Android",
LastName: "Signup",
},
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
in.PutID(0x80eee427)
in.PutString("+15550004242")
in.PutString("phone-code-hash")
in.PutString("Android")
in.PutString("Signup")
enc, err := r.Dispatch(androidClientContext(), authKeyID, 777, &in)
if err != nil {
t.Fatalf("dispatch legacy auth.signUp: %v", err)
}
// Routed through the unified layerwire inbound upgrade + the normal gotd
// dispatcher, which boxes a class result (auth.Authorization) as *...Box.
box, ok := enc.(*tg.AuthAuthorizationBox)
if !ok {
t.Fatalf("response = %T, want *tg.AuthAuthorizationBox", enc)
}
authorization, ok := box.Authorization.(*tg.AuthAuthorization)
if !ok {
t.Fatalf("authorization = %T, want auth.authorization", box.Authorization)
}
user, ok := authorization.User.(*tg.User)
if !ok || user.ID != auth.signUpUser.ID {
t.Fatalf("authorization user = %T %+v, want user %d", authorization.User, authorization.User, auth.signUpUser.ID)
}
if auth.signUpPhone != "+15550004242" ||
auth.signUpHash != "phone-code-hash" ||
auth.signUpFirstName != "Android" ||
auth.signUpLastName != "Signup" {
t.Fatalf("signup args = phone %q hash %q first %q last %q", auth.signUpPhone, auth.signUpHash, auth.signUpFirstName, auth.signUpLastName)
}
if auth.signUpAuth.AuthKeyID != authKeyID {
t.Fatalf("signup auth key = %x, want %x", auth.signUpAuth.AuthKeyID, authKeyID)
}
}
func TestModernAndroidChannelsInviteToChannelDispatch(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550001091", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550001092", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Android Channel",
About: "invite compat",
Broadcast: true,
Date: 1700000091,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
// DrKLO channels.inviteToChannel#199f3a6c — now handled by the unified
// layerwire client-alias table (pure id swap), not a dedicated handler.
in.PutID(0x199f3a6c)
if err := (&tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
}).EncodeBare(&in); err != nil {
t.Fatalf("encode modern inviteToChannel body: %v", err)
}
enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch modern inviteToChannel: %v", err)
}
invited, ok := enc.(*tg.MessagesInvitedUsers)
if !ok {
t.Fatalf("response = %T, want messages.invitedUsers", enc)
}
if invited.Updates == nil || len(invited.MissingInvitees) != 0 {
t.Fatalf("invited users = %+v, want updates and no missing users", invited)
}
member, err := channelService.GetParticipant(ctx, owner.ID, created.Channel.ID, friend.ID)
if err != nil {
t.Fatalf("get invited participant: %v", err)
}
if member.UserID != friend.ID || member.Status != domain.ChannelMemberActive {
t.Fatalf("participant = %+v, want active friend", member)
}
view, err := channelService.GetChannel(ctx, owner.ID, created.Channel.ID)
if err != nil {
t.Fatalf("get channel: %v", err)
}
if view.Channel.ParticipantsCount != 2 {
t.Fatalf("participants_count = %d, want 2", view.Channel.ParticipantsCount)
}
}
func TestLegacyAndroidBotsExportBotTokenDispatch(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
botStore := memory.NewBotStore(userStore)
dialogStore := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogStore)
botService := botsapp.NewService(userStore, botStore, messageStore)
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93, Phone: "15550001093", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
bot, token, err := botService.CreateBot(ctx, owner.ID, "Android Export", "android_export_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Bots: botService,
}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
in.PutID(0x0063b089)
in.PutLong(bot.ID)
if err := (&tg.BoolFalse{}).Encode(&in); err != nil {
t.Fatalf("encode revoke bool: %v", err)
}
enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch legacy exportBotToken: %v", err)
}
exported, ok := enc.(*tg.BotsExportedBotToken)
if !ok {
t.Fatalf("response = %T, want bots.exportedBotToken", enc)
}
if exported.Token != token {
t.Fatalf("token = %q, want %q", exported.Token, token)
}
}
func TestMessagesCreateChatRejectsNegativeTTLPeriod(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550001081", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550001082", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
}, zaptest.NewLogger(t), clock.System)
_, err = r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Bad TTL",
TTLPeriod: -1,
})
if err == nil || !strings.Contains(err.Error(), "TTL_PERIOD_INVALID") {
t.Fatalf("createChat err = %v, want TTL_PERIOD_INVALID", err)
}
}
func TestStoriesCanSendStoryDispatchReturnsPositiveRemainingCount(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
var in bin.Buffer
if err := (&tg.StoriesCanSendStoryRequest{Peer: &tg.InputPeerSelf{}}).Encode(&in); err != nil {
t.Fatalf("encode canSendStory: %v", err)
}
enc, err := r.Dispatch(WithUserID(androidClientContext(), 1000000001), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch canSendStory: %v", err)
}
count, ok := enc.(*tg.StoriesCanSendStoryCount)
if !ok {
t.Fatalf("response = %T, want stories.canSendStoryCount", enc)
}
if count.CountRemains <= 0 {
t.Fatalf("count_remains = %d, want positive", count.CountRemains)
}
}

View file

@ -13,7 +13,7 @@ import (
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/app/auth"
"telesrv/internal/domain"
)
@ -37,20 +37,21 @@ func (r *Router) registerAuth(d *tg.ServerDispatcher) {
d.OnAuthDropTempAuthKeys(func(ctx context.Context, exceptauthkeys []int64) (bool, error) {
return true, nil
})
d.OnAuthInitPasskeyLogin(func(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) {
return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: "{}"}}, nil
})
d.OnAuthInitPasskeyLogin(r.onAuthInitPasskeyLogin)
d.OnAuthFinishPasskeyLogin(r.onAuthFinishPasskeyLogin)
d.OnAuthSendCode(r.onAuthSendCode)
d.OnAuthResendCode(r.onAuthResendCode)
d.OnAuthCancelCode(r.onAuthCancelCode)
d.OnAuthSignIn(r.onAuthSignIn)
d.OnAuthSignUp(r.onAuthSignUp)
d.OnAuthImportBotAuthorization(r.onAuthImportBotAuthorization)
d.OnAuthLogOut(r.onAuthLogOut)
d.OnAuthResetAuthorizations(r.onAuthResetAuthorizations)
d.OnAuthCheckPassword(r.onAuthCheckPassword)
d.OnAuthRequestPasswordRecovery(r.onAuthRequestPasswordRecovery)
d.OnAuthRecoverPassword(r.onAuthRecoverPassword)
d.OnAuthCheckRecoveryPassword(r.onAuthCheckRecoveryPassword)
d.OnAuthResetLoginEmail(r.onAuthResetLoginEmail)
}
// onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。
@ -72,6 +73,11 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
}); err != nil {
return false, bindTempAuthKeyErr(err)
}
// temp key (re)bind 后立即作废其 temp→perm 解析缓存,确保下一帧按新绑定重新解析,
// 不被 TTL 内的旧 perm 缓存命中(防跨账号串号)。
if id != ([8]byte{}) {
r.tempKeyResolveCache.Delete(id)
}
if r.deps.Sessions != nil {
if scoped, ok := r.scopedSessions(); ok {
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
@ -84,32 +90,185 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
return true, nil
}
// onAuthExportLoginToken 给 TDesktop QR 登录页返回一个短期占位 token。
func (r *Router) onAuthExportLoginToken(ctx context.Context, _ *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) {
id, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil
}
func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) {
id, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil
}
func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) {
return nil, authTokenInvalidErr()
}
// onAuthSendCode 处理 auth.sendCode生成 phone_code_hash 并返回 sentCode。
func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) {
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
// onAuthExportLoginToken 给 QR 登录请求方返回短期 token扫码端接受后同一目标
// session 后续 export 会升级为 auth.loginTokenSuccess。
func (r *Router) onAuthExportLoginToken(ctx context.Context, req *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) {
target, ok := loginTokenTargetFromContext(ctx)
if !ok {
return nil, internalErr()
}
authz := r.authzFromCtx(ctx)
result, err := r.loginTokens.export(r.clock.Now(), target, authz, req.ExceptIDs)
if err != nil {
return nil, internalErr()
}
if result.accepted {
return r.authLoginTokenSuccess(ctx, result.acceptedAuth)
}
return &tg.AuthLoginToken{Expires: int(result.expires.Unix()), Token: result.token}, nil
}
func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) {
result, err := r.loginTokens.lookup(r.clock.Now(), token)
if err != nil {
return nil, err
}
if result.accepted {
return r.authLoginTokenSuccess(ctx, result.acceptedAuth)
}
return &tg.AuthLoginToken{Expires: int(result.expires.Unix()), Token: result.token}, nil
}
func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) {
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Auth == nil {
return nil, internalErr()
}
now := r.clock.Now()
accept, err := r.loginTokens.beginAccept(now, token, userID)
if err != nil {
return nil, err
}
scannerAuthKeyID, _ := AuthKeyIDFrom(ctx)
if scannerAuthKeyID != ([8]byte{}) && scannerAuthKeyID == accept.authz.AuthKeyID {
r.loginTokens.failAccept(token)
return nil, authTokenExceptionErr()
}
authz := accept.authz
authz.UserID = userID
authz.PasswordPending = false
if err := r.clearAuthKeyState(ctx, authz.AuthKeyID); err != nil {
r.loginTokens.failAccept(token)
return nil, internalErr()
}
bound, err := r.deps.Auth.AcceptLoginToken(ctx, authz, userID)
if err != nil {
r.loginTokens.failAccept(token)
if errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, authKeyUnregisteredErr()
}
return nil, internalErr()
}
if bound.UserID == 0 {
bound.UserID = userID
}
r.loginTokens.finishAccept(now, token, userID, bound)
r.invalidateAuthUserCache(bound.AuthKeyID)
r.setAuthUserCache(bound.AuthKeyID, userID, true)
r.bindLoginTokenTarget(accept.target, userID)
r.pushLoginTokenAccepted(ctx, accept.target)
out := tgAuthorization(bound, scannerAuthKeyID, int(now.Unix()))
return &out, nil
}
func loginTokenTargetFromContext(ctx context.Context) (loginTokenTarget, bool) {
authKeyID, _ := AuthKeyIDFrom(ctx)
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
if rawAuthKeyID == ([8]byte{}) {
rawAuthKeyID = authKeyID
}
sessionID, _ := SessionIDFrom(ctx)
return loginTokenTarget{rawAuthKeyID: rawAuthKeyID, authKeyID: authKeyID, sessionID: sessionID}, true
}
func (r *Router) authLoginTokenSuccess(ctx context.Context, a domain.Authorization) (tg.AuthLoginTokenClass, error) {
if r.deps.Users == nil || a.UserID == 0 {
return nil, internalErr()
}
u, err := r.deps.Users.Self(ctx, a.UserID)
if err != nil {
return nil, internalErr()
}
return &tg.AuthLoginTokenSuccess{
Authorization: &tg.AuthAuthorization{User: r.tgSelfUser(u)},
}, nil
}
func (r *Router) bindLoginTokenTarget(target loginTokenTarget, userID int64) {
if r.deps.Sessions == nil || target.sessionID == 0 {
return
}
if scoped, ok := r.scopedSessions(); ok && target.rawAuthKeyID != ([8]byte{}) {
scoped.BindAuthKeyForSession(target.rawAuthKeyID, target.sessionID, target.authKeyID)
scoped.BindUserForAuthKey(target.rawAuthKeyID, target.sessionID, userID)
r.announceSessionOnline(loginTokenTargetContext(target, userID), userID)
return
}
r.deps.Sessions.BindAuthKey(target.sessionID, target.authKeyID)
r.deps.Sessions.BindUser(target.sessionID, userID)
r.announceSessionOnline(loginTokenTargetContext(target, userID), userID)
}
func loginTokenTargetContext(target loginTokenTarget, userID int64) context.Context {
ctx := context.Background()
ctx = WithRawAuthKeyID(ctx, target.rawAuthKeyID)
ctx = WithAuthKeyID(ctx, target.authKeyID)
ctx = WithSessionID(ctx, target.sessionID)
ctx = WithUserID(ctx, userID)
return ctx
}
func (r *Router) pushLoginTokenAccepted(ctx context.Context, target loginTokenTarget) {
if r.deps.Sessions == nil || target.sessionID == 0 {
return
}
updates := &tg.UpdateShort{
Update: &tg.UpdateLoginToken{},
Date: int(r.clock.Now().Unix()),
}
if immediate, ok := r.deps.Sessions.(ScopedImmediateSessionPusher); ok && target.rawAuthKeyID != ([8]byte{}) {
if err := immediate.PushToSessionForAuthKeyImmediate(ctx, target.rawAuthKeyID, target.sessionID, proto.MessageFromServer, updates); err != nil {
r.log.Debug("push login token accepted immediate", zap.Int64("session_id", target.sessionID), zap.Error(err))
}
return
}
if scoped, ok := r.scopedSessions(); ok && target.rawAuthKeyID != ([8]byte{}) {
if err := scoped.PushToSessionForAuthKey(ctx, target.rawAuthKeyID, target.sessionID, proto.MessageFromServer, updates); err != nil {
r.log.Debug("push login token accepted", zap.Int64("session_id", target.sessionID), zap.Error(err))
}
return
}
if err := r.deps.Sessions.PushToSession(ctx, target.sessionID, proto.MessageFromServer, updates); err != nil {
r.log.Debug("push login token accepted", zap.Int64("session_id", target.sessionID), zap.Error(err))
}
}
// onAuthSendCode 处理 auth.sendCode生成 phone_code_hash 并返回 sentCode。
// 若该手机号账号设置了登录邮箱,验证码改投递到邮箱,返回 sentCodeTypeEmailCode
// (客户端据此进入"输入邮箱验证码"界面,随后用 auth.signIn 的 email_verification 完成登录)。
func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) {
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
if err != nil {
if errors.Is(err, auth.ErrPhoneNumberInvalid) ||
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
}
return nil, internalErr()
}
if pattern, ok := r.loginEmailPattern(ctx, req.PhoneNumber); ok {
return tgEmailSentCode(hash, pattern), nil
}
return tgSentCode(hash), nil
}
// loginEmailPattern 返回该手机号账号已确认登录邮箱的掩码,不存在则 ok=false。
func (r *Router) loginEmailPattern(ctx context.Context, phone string) (string, bool) {
if r.deps.Account == nil {
return "", false
}
email, found, err := r.deps.Account.LoginEmailByPhone(ctx, phone)
if err != nil || !found || email == "" {
return "", false
}
return domain.MaskEmail(email), true
}
func tgSentCode(hash string) tg.AuthSentCodeClass {
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength},
@ -117,18 +276,46 @@ func tgSentCode(hash string) tg.AuthSentCodeClass {
}
}
func tgEmailSentCode(hash, emailPattern string) tg.AuthSentCodeClass {
codeType := &tg.AuthSentCodeTypeEmailCode{
EmailPattern: emailPattern,
Length: devCodeLength,
}
// reset_available_period=0 表示可立即调用 auth.resetLoginEmail开发环境无等待期
// 让客户端的"无法访问邮箱?"逃生入口可用。
codeType.SetResetAvailablePeriod(0)
return &tg.AuthSentCode{
Type: codeType,
PhoneCodeHash: hash,
}
}
// onAuthSignIn 处理 auth.signIn校验验证码用户不存在时返回 SignUpRequired。
// 带 email_verification 时走登录邮箱路径(验证码来自邮箱而非短信)。
func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) {
u, loginMessage, needSignUp, err := r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
var (
u domain.User
loginMessage domain.Message
needSignUp bool
err error
)
if verification, ok := req.GetEmailVerification(); ok {
u, loginMessage, needSignUp, err = r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, emailVerificationCode(verification))
} else {
u, loginMessage, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
}
if err != nil {
if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 {
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
// 两步验证未完成:绝不能把 auth_key/session 标记为已登录,否则客户端忽略
// SESSION_PASSWORD_NEEDED、直接调用业务 RPC 即可绕过 2FA。失效缓存并把 session
// 置为未授权,让后续鉴权重新读到 password_pending 并拒绝;待 checkPassword 通过后再授权。
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
r.invalidateAuthUserCache(id)
}
r.bindSessionUser(ctx, u.ID)
r.bindSessionUser(ctx, 0)
}
return nil, signInErr(err)
}
@ -173,24 +360,35 @@ func (r *Router) onAuthResetAuthorizations(ctx context.Context) (bool, error) {
return false, internalErr()
}
for _, a := range deleted {
r.invalidateAuthUserCache(a.AuthKeyID)
r.unbindAuthKey(a.AuthKeyID)
r.revokeAuthKeySessions(a.AuthKeyID)
_ = r.clearAuthKeyState(ctx, a.AuthKeyID)
// P1 修复:撤销其它会话同样销毁其 auth_key级联 discard 该设备绑定的活跃密聊并通知对端。
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(a.AuthKeyID), userID)
}
return true, nil
}
func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheckPasswordSRPClass) (tg.AuthAuthorizationClass, error) {
userID, _, err := r.currentUserID(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
userID, authorized, pending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !pending && (!authorized || userID == 0) {
return nil, passwordHashInvalidErr()
}
if r.deps.Account == nil {
return nil, passwordHashInvalidErr()
}
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(password)); err != nil {
return nil, passwordErr(err)
}
// 两步验证通过:清除 password_pending 并把 auth_key/session 提升为完全授权。
if pending {
if err := r.completePendingPasswordSignIn(ctx, authKeyID, userID); err != nil {
return nil, internalErr()
}
}
u, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
@ -199,7 +397,7 @@ func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheck
}
func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPasswordRecovery, error) {
userID, _, err := r.currentUserID(ctx)
userID, _, _, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
@ -211,7 +409,8 @@ func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPas
}
func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverPasswordRequest) (tg.AuthAuthorizationClass, error) {
userID, _, err := r.currentUserID(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
userID, _, pending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return nil, internalErr()
}
@ -226,6 +425,11 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP
if err := r.deps.Account.RecoverPassword(ctx, userID, req.Code, input); err != nil {
return nil, passwordErr(err)
}
if pending {
if err := r.completePendingPasswordSignIn(ctx, authKeyID, userID); err != nil {
return nil, internalErr()
}
}
u, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
@ -234,7 +438,7 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP
}
func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (bool, error) {
userID, _, err := r.currentUserID(ctx)
userID, _, _, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return false, internalErr()
}
@ -244,6 +448,143 @@ func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (
return true, nil
}
// currentOrPendingPasswordUserID returns the fully authorized user when present;
// otherwise it allows the narrow 2FA login continuation path to locate the user
// attached to a password_pending auth key. The pending identity must not be used
// by general business RPCs.
func (r *Router) currentOrPendingPasswordUserID(ctx context.Context) (userID int64, authorized bool, passwordPending bool, err error) {
userID, authorized, err = r.currentUserID(ctx)
if err != nil || authorized {
return userID, authorized, false, err
}
if r.deps.Auth == nil {
return userID, authorized, false, nil
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok {
return userID, authorized, false, nil
}
pendingUserID, pending, err := r.deps.Auth.PendingPasswordUserID(ctx, authKeyID)
if err != nil {
return 0, false, false, err
}
if !pending || pendingUserID == 0 {
return userID, authorized, false, nil
}
return pendingUserID, false, true, nil
}
func (r *Router) completePendingPasswordSignIn(ctx context.Context, authKeyID [8]byte, userID int64) error {
if r.deps.Auth == nil {
return nil
}
if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID); err != nil {
return err
}
r.invalidateAuthUserCache(authKeyID)
r.setAuthUserCache(authKeyID, userID, true)
r.bindSessionUser(ctx, userID)
return nil
}
// onAuthResetLoginEmail 处理 auth.resetLoginEmail用户登录设备时无法访问登录邮箱时
// 清除登录邮箱,改回手机验证码登录,返回一个新的手机 sentCode 供其继续。
func (r *Router) onAuthResetLoginEmail(ctx context.Context, req *tg.AuthResetLoginEmailRequest) (tg.AuthSentCodeClass, error) {
if r.deps.Account == nil || r.deps.Auth == nil {
return nil, internalErr()
}
if err := r.deps.Account.ClearLoginEmailByPhone(ctx, req.PhoneNumber); err != nil {
return nil, internalErr()
}
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
if err != nil {
if errors.Is(err, auth.ErrPhoneNumberInvalid) ||
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
}
return nil, internalErr()
}
return tgSentCode(hash), nil
}
// emailVerificationCode 从 emailVerification 取出可校验的字符串(验证码 / Google·Apple
// 令牌)。开发环境一律按"任意非空即通过"处理,故三者等价取值。
// onAuthInitPasskeyLogin 处理 auth.initPasskeyLogin生成一次性断言挑战discoverable
// 以 DataJSON顶层含 publicKey返回。免授权登录前
func (r *Router) onAuthInitPasskeyLogin(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) {
if r.deps.Passkey == nil {
return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: "{}"}}, nil
}
options, err := r.deps.Passkey.InitLogin(ctx)
if err != nil {
return nil, internalErr()
}
return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: string(options)}}, nil
}
// onAuthFinishPasskeyLogin 处理 auth.finishPasskeyLogin验证登录断言并绑定 auth_key。
// 收尾与 signIn 同构清水位→授权缓存→session 绑定passkey 是强因子,直接完全授权
// (不走 SESSION_PASSWORD_NEEDED。FromDCID/FromAuthKeyID 为多 DC 重路由用,本单 DC 忽略。
func (r *Router) onAuthFinishPasskeyLogin(ctx context.Context, req *tg.AuthFinishPasskeyLoginRequest) (tg.AuthAuthorizationClass, error) {
if r.deps.Passkey == nil || r.deps.Auth == nil {
return nil, internalErr()
}
credID, login, ok := passkeyLoginFromCredential(req.Credential)
if !ok {
return nil, passkeyErr(domain.ErrPasskeyInvalid)
}
userID, err := r.deps.Passkey.FinishLogin(ctx, credID, []byte(login.ClientData.Data), login.AuthenticatorData, login.Signature, login.UserHandle)
if err != nil {
return nil, passkeyErr(err)
}
u, err := r.deps.Auth.BindVerifiedLogin(ctx, r.authzFromCtx(ctx), userID)
if err != nil {
return nil, passkeyErr(err)
}
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func emailVerificationCode(v tg.EmailVerificationClass) string {
switch e := v.(type) {
case *tg.EmailVerificationCode:
return e.Code
case *tg.EmailVerificationGoogle:
return e.Token
case *tg.EmailVerificationApple:
return e.Token
}
return ""
}
// onAuthImportBotAuthorization 处理 auth.importBotAuthorizationbot 程序凭 token
// 登录为 bot 账号。api_id/api_hash 与现有 sendCode 行为一致不校验(无 app 注册表)。
// 收尾与 signIn 同构清水位→授权缓存→session 绑定),但不写登录消息、不推
// signIn 服务通知——那是手机登录语义。
func (r *Router) onAuthImportBotAuthorization(ctx context.Context, req *tg.AuthImportBotAuthorizationRequest) (tg.AuthAuthorizationClass, error) {
if r.deps.Auth == nil {
return nil, accessTokenInvalidErr()
}
u, err := r.deps.Auth.SignInBot(ctx, r.authzFromCtx(ctx), req.BotAuthToken)
if err != nil {
return nil, importBotAuthorizationErr(err)
}
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
r.bindSessionUser(ctx, u.ID)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
// onAuthSignUp 处理 auth.signUp创建用户并绑定授权。
func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (tg.AuthAuthorizationClass, error) {
u, loginMessage, err := r.deps.Auth.SignUp(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.FirstName, req.LastName)
@ -270,9 +611,21 @@ func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) {
}
r.invalidateAuthUserCache(id)
r.unbindAuthKey(id)
if userErr == nil && authorized && userID != 0 {
status := r.setPresenceFromContext(ctx, userID, true)
// bot 登出不广播 offlinebot 无 presence 语义,与登录路径对称)。
if userErr == nil && authorized && userID != 0 && !r.userIsBot(ctx, userID) {
status, _ := r.setPresenceFromContext(ctx, userID, true, presencePersistSync)
r.pushUserStatus(ctx, userID, status)
// 登出后主动清掉本 session 的 presence 条目:连接通常不断开(客户端回登录页),
// 上面 unbindAuthKey 已把连接 userID 清 0TCP 真正断开时 SessionOffline 因 userID=0
// 提前返回、不再清 presence条目会以 offline 态滞留泄露。这里随登出一并清除。
if key, ok := presenceSessionKeyFromContext(ctx); ok {
r.presence.clearSession(key)
}
}
// P1 修复:登出销毁本设备 perm auth_key 后,级联 discard 其绑定的活跃密聊并通知对端
//(否则对端继续往死 auth_key 投递成静默死链。best-effort不阻断登出。
if userErr == nil && userID != 0 {
r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(id), userID)
}
if err := r.clearAuthKeyState(ctx, id); err != nil {
return nil, internalErr()
@ -324,6 +677,38 @@ func (r *Router) unbindAuthKey(authKeyID [8]byte) {
r.deps.Sessions.UnbindAuthKey(authKeyID)
}
// revokeAuthKeySessions 是授权撤销(被踢设备)的完整失效闭环:清 Router 授权缓存、
// 清 temp→perm 短缓存、强制断开在线连接、再兜底解绑。断开不可省略——出站推送用
// 连接持有的密钥加密、不回查授权表perm-key 连接的授权缓存也只有重连才会重新回查;
// 不断开的话被踢设备仍能持续收到推送并以缓存身份继续发请求。重连后回查 store 即得
// 未授权401
//
// 顺序关键:先 CloseSessionsForBusinessAuthKey 再 unbindAuthKey。Close 内部 removeLocked
// 读取连接当前 userID 生成 SessionOffline 事件(驱动 presence 清理与 offline 广播);
// 若先 unbind 把 userID 清成 0事件就退化为 userID=0被踢设备的 presence 条目不被
// 清理、好友侧最长一个在线 TTL 仍显示其在线。Close 已把连接移出索引,随后的 unbind
// 对未实现 SessionTerminator 的 Sessions 才有意义(生产实现走 Close 即可unbind 是 no-op
func (r *Router) revokeAuthKeySessions(authKeyID [8]byte) {
r.invalidateAuthUserCache(authKeyID)
rawTempAuthKeyIDs := r.invalidateTempAuthKeyCacheForPerm(authKeyID)
if terminator, ok := r.deps.Sessions.(SessionTerminator); ok {
terminator.CloseSessionsForBusinessAuthKey(authKeyID)
}
if terminator, ok := r.deps.Sessions.(RawSessionTerminator); ok {
for _, rawAuthKeyID := range rawTempAuthKeyIDs {
if rawAuthKeyID == authKeyID {
continue
}
terminator.CloseSessionsForRawAuthKeyExcept(rawAuthKeyID, 0)
}
}
r.unbindAuthKey(authKeyID)
}
func (r *Router) invalidateTempAuthKeyCacheForPerm(authKeyID [8]byte) [][8]byte {
return r.tempKeyResolveCache.DeleteByPerm(authKeyID)
}
func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u domain.User) {
if r.deps.Sessions == nil || u.ID == 0 {
return

59
internal/rpc/auth_gate.go Normal file
View file

@ -0,0 +1,59 @@
package rpc
import "github.com/gotd/td/tg"
// rpcAllowedWithoutAuthorization returns true for methods that are valid before
// an auth key is bound to a user. Everything else must fail with
// AUTH_KEY_UNREGISTERED so stale Web/desktop sessions fall back to login.
//
// Inbound layer/client-drift upgrades run before this gate (see router.dispatch),
// so ids here are always canonical (227) — old client constructor ids never reach
// this check.
func rpcAllowedWithoutAuthorization(id uint32) bool {
switch id {
case tg.AuthBindTempAuthKeyRequestTypeID,
tg.AuthExportLoginTokenRequestTypeID,
tg.AuthImportLoginTokenRequestTypeID,
tg.AuthAcceptLoginTokenRequestTypeID,
tg.AuthInitPasskeyLoginRequestTypeID,
tg.AuthFinishPasskeyLoginRequestTypeID,
tg.AuthDropTempAuthKeysRequestTypeID,
tg.AuthSendCodeRequestTypeID,
tg.AuthResendCodeRequestTypeID,
tg.AuthCancelCodeRequestTypeID,
tg.AuthSignInRequestTypeID,
tg.AuthSignUpRequestTypeID,
tg.AuthImportBotAuthorizationRequestTypeID,
tg.AuthCheckPasswordRequestTypeID,
tg.AuthRequestPasswordRecoveryRequestTypeID,
tg.AuthRecoverPasswordRequestTypeID,
tg.AuthCheckRecoveryPasswordRequestTypeID,
tg.AuthRequestFirebaseSMSRequestTypeID,
tg.AuthReportMissingCodeRequestTypeID,
tg.AuthResetLoginEmailRequestTypeID,
tg.AccountGetPasswordRequestTypeID,
// 登录邮箱 setupemailVerifyPurposeLoginSetup发生在登录流程中、尚未鉴权
// 故这两个 account.* 方法必须放行 pre-authloginChange 分支内部仍校验 userID。
tg.AccountSendVerifyEmailCodeRequestTypeID,
tg.AccountVerifyEmailRequestTypeID,
tg.HelpGetConfigRequestTypeID,
tg.HelpGetNearestDCRequestTypeID,
tg.HelpGetInviteTextRequestTypeID,
tg.HelpGetAppConfigRequestTypeID,
tg.HelpGetCountriesListRequestTypeID,
tg.HelpGetTimezonesListRequestTypeID,
tg.HelpGetPeerColorsRequestTypeID,
tg.HelpGetPeerProfileColorsRequestTypeID,
tg.HelpGetPromoDataRequestTypeID,
tg.HelpGetTermsOfServiceUpdateRequestTypeID,
tg.HelpGetPremiumPromoRequestTypeID,
tg.LangpackGetLanguagesRequestTypeID,
tg.LangpackGetLanguageRequestTypeID,
tg.LangpackGetLangPackRequestTypeID,
tg.LangpackGetDifferenceRequestTypeID,
tg.LangpackGetStringsRequestTypeID:
return true
default:
return false
}
}

View file

@ -0,0 +1,103 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountGetPasswordUsesPendingPasswordUser(t *testing.T) {
ctx := pendingPasswordContext()
const userID int64 = 42
passwords := memory.NewPasswordStore()
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
HasPassword: true,
Hint: "q1",
SRPID: 99,
SRPVerifier: []byte{1},
}); err != nil {
t.Fatalf("save password: %v", err)
}
router := New(Config{}, Deps{
Auth: &captureAuthService{
pendingPasswordUserID: userID,
pendingPassword: true,
},
Account: appaccount.NewService(passwords),
}, zaptest.NewLogger(t), clock.System)
got, err := router.onAccountGetPassword(ctx)
if err != nil {
t.Fatalf("account.getPassword: %v", err)
}
if !got.HasPassword || got.Hint != "q1" || got.SRPID == 0 || len(got.SRPB) == 0 || got.CurrentAlgo == nil {
t.Fatalf("password challenge = %+v, want pending user's SRP challenge", got)
}
}
func TestAuthRecoverPasswordCompletesPendingSignIn(t *testing.T) {
ctx := pendingPasswordContext()
authKeyID, _ := AuthKeyIDFrom(ctx)
const userID int64 = 42
passwords := memory.NewPasswordStore()
if err := passwords.Save(ctx, userID, domain.PasswordSettings{
HasPassword: true,
Hint: "q1",
SRPID: 99,
SRPVerifier: []byte{1},
RecoveryEmail: "alice@example.com",
}); err != nil {
t.Fatalf("save password: %v", err)
}
auth := &captureAuthService{
pendingPasswordUserID: userID,
pendingPassword: true,
}
sessions := &captureSessions{}
router := New(Config{}, Deps{
Auth: auth,
Account: appaccount.NewService(passwords),
Users: staticUsersService{user: domain.User{ID: userID, AccessHash: 7, Phone: "15550000042", FirstName: "Alice"}},
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
if _, err := router.onAuthRequestPasswordRecovery(ctx); err != nil {
t.Fatalf("auth.requestPasswordRecovery: %v", err)
}
if _, err := router.onAuthRecoverPassword(ctx, &tg.AuthRecoverPasswordRequest{Code: "12345"}); err != nil {
t.Fatalf("auth.recoverPassword: %v", err)
}
if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID {
t.Fatalf("CompletePasswordSignIn count=%d key=%x, want one call for %x", auth.completePasswordCount, auth.completedPasswordKey, authKeyID)
}
if snap := sessions.snapshot(); snap.userID != userID || !snap.userResolved {
t.Fatalf("session user = %d resolved=%v, want %d resolved", snap.userID, snap.userResolved, userID)
}
cleared, _, err := passwords.GetByUser(ctx, userID)
if err != nil {
t.Fatalf("get recovered password: %v", err)
}
if cleared.HasPassword {
t.Fatalf("password still enabled after recover: %+v", cleared)
}
}
func pendingPasswordContext() context.Context {
authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
ctx := context.Background()
ctx = WithAuthKeyID(ctx, authKeyID)
ctx = WithRawAuthKeyID(ctx, authKeyID)
ctx = WithSessionID(ctx, 77)
return ctx
}

View file

@ -0,0 +1,196 @@
package rpc
import (
"bytes"
"context"
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestAuthLoginTokenAcceptedByAndroidBindsTargetSession(t *testing.T) {
const (
targetSession = int64(101)
scannerSession = int64(202)
scannerUserID = int64(1000000001)
)
targetRawAuthKeyID := [8]byte{0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80}
targetAuthKeyID := [8]byte{0x81, 0x71, 0x61, 0x51, 0x41, 0x31, 0x21, 0x11}
scannerRawAuthKeyID := [8]byte{0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99}
scannerAuthKeyID := [8]byte{0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22}
auth := &captureAuthService{}
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
users := mapUsersService{users: map[int64]domain.User{
scannerUserID: {ID: scannerUserID, FirstName: "Alice", Phone: "15550001001"},
}}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Auth: auth,
Sessions: sessions,
Users: users,
}, zaptest.NewLogger(t), clock.System)
targetCtx := WithClientInfo(
WithLayer(
WithSessionID(
WithAuthKeyID(
WithRawAuthKeyID(context.Background(), targetRawAuthKeyID),
targetAuthKeyID,
),
targetSession,
),
currentClientLayer,
),
ClientInfo{
APIID: 2040,
DeviceModel: "WebA",
SystemVersion: "Chrome",
AppVersion: "1.0",
LangPack: "tdesktop",
},
)
exported, err := r.onAuthExportLoginToken(targetCtx, &tg.AuthExportLoginTokenRequest{APIID: 2040, APIHash: "hash"})
if err != nil {
t.Fatalf("export login token: %v", err)
}
loginToken, ok := exported.(*tg.AuthLoginToken)
if !ok {
t.Fatalf("export type = %T, want *tg.AuthLoginToken", exported)
}
if len(loginToken.Token) != loginTokenBytes {
t.Fatalf("token length = %d, want %d", len(loginToken.Token), loginTokenBytes)
}
unauthorizedScannerCtx := WithSessionID(
WithAuthKeyID(
WithRawAuthKeyID(context.Background(), scannerRawAuthKeyID),
scannerAuthKeyID,
),
scannerSession,
)
if _, err := r.onAuthAcceptLoginToken(unauthorizedScannerCtx, loginToken.Token); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") {
t.Fatalf("unauthorized accept err = %v, want AUTH_KEY_UNREGISTERED", err)
}
scannerCtx := WithUserID(unauthorizedScannerCtx, scannerUserID)
authorization, err := r.onAuthAcceptLoginToken(scannerCtx, loginToken.Token)
if err != nil {
t.Fatalf("accept login token: %v", err)
}
if authorization == nil {
t.Fatal("accept login token returned nil authorization")
}
if auth.acceptedUserID != scannerUserID {
t.Fatalf("accepted user id = %d, want %d", auth.acceptedUserID, scannerUserID)
}
if auth.acceptedAuth.AuthKeyID != targetAuthKeyID {
t.Fatalf("accepted auth key = %x, want %x", auth.acceptedAuth.AuthKeyID, targetAuthKeyID)
}
if auth.acceptedAuth.DeviceModel != "WebA" {
t.Fatalf("accepted device = %q, want WebA", auth.acceptedAuth.DeviceModel)
}
if authorization.Hash == 0 {
t.Fatal("authorization hash is zero")
}
snap := sessions.snapshot()
if sessions.scopedAuthKeyID != targetRawAuthKeyID {
t.Fatalf("scoped raw auth key = %x, want %x", sessions.scopedAuthKeyID, targetRawAuthKeyID)
}
if snap.sessionID != targetSession || snap.userID != scannerUserID || !snap.userResolved {
t.Fatalf("target session snapshot = %+v, want session/user/resolved %d/%d/true", snap, targetSession, scannerUserID)
}
if snap.messageType != proto.MessageFromServer {
t.Fatalf("push message type = %v, want MessageFromServer", snap.messageType)
}
if !sessions.immediatePush {
t.Fatal("login token update was not pushed through the immediate pre-auth path")
}
short, ok := snap.message.(*tg.UpdateShort)
if !ok {
t.Fatalf("push message = %T, want *tg.UpdateShort", snap.message)
}
if _, ok := short.Update.(*tg.UpdateLoginToken); !ok {
t.Fatalf("pushed update = %T, want *tg.UpdateLoginToken", short.Update)
}
success, err := r.onAuthExportLoginToken(targetCtx, &tg.AuthExportLoginTokenRequest{APIID: 2040, APIHash: "hash"})
if err != nil {
t.Fatalf("export after accept: %v", err)
}
loginSuccess, ok := success.(*tg.AuthLoginTokenSuccess)
if !ok {
t.Fatalf("export after accept type = %T, want *tg.AuthLoginTokenSuccess", success)
}
authz, ok := loginSuccess.Authorization.(*tg.AuthAuthorization)
if !ok {
t.Fatalf("success authorization = %T, want *tg.AuthAuthorization", loginSuccess.Authorization)
}
user, ok := authz.User.(*tg.User)
if !ok {
t.Fatalf("success user = %T, want *tg.User", authz.User)
}
if user.ID != scannerUserID {
t.Fatalf("success user id = %d, want %d", user.ID, scannerUserID)
}
if _, err := r.onAuthAcceptLoginToken(scannerCtx, loginToken.Token); !tgerr.Is(err, "AUTH_TOKEN_ALREADY_ACCEPTED") {
t.Fatalf("duplicate accept err = %v, want AUTH_TOKEN_ALREADY_ACCEPTED", err)
}
}
func TestAuthLoginTokenExpires(t *testing.T) {
now := time.Unix(1700000000, 0)
reg := newLoginTokenRegistry()
target := loginTokenTarget{
rawAuthKeyID: [8]byte{1},
authKeyID: [8]byte{2},
sessionID: 3,
}
exported, err := reg.export(now, target, domain.Authorization{AuthKeyID: target.authKeyID}, nil)
if err != nil {
t.Fatalf("export: %v", err)
}
if _, err := reg.beginAccept(now.Add(loginTokenTTL+time.Second), exported.token, 1000000001); !tgerr.Is(err, "AUTH_TOKEN_EXPIRED") {
t.Fatalf("expired accept err = %v, want AUTH_TOKEN_EXPIRED", err)
}
}
func TestLoginTokenRegistryCapacityBounded(t *testing.T) {
now := time.Unix(1700000000, 0)
reg := newLoginTokenRegistry()
targets := make([]loginTokenTarget, 0, loginTokenMaxRecords)
var last loginTokenExport
for i := 0; i < loginTokenMaxRecords; i++ {
target := loginTokenTarget{rawAuthKeyID: [8]byte{byte(i), 1}, authKeyID: [8]byte{byte(i), 2}, sessionID: int64(i + 1)}
targets = append(targets, target)
exported, err := reg.export(now, target, domain.Authorization{AuthKeyID: target.authKeyID}, nil)
if err != nil {
t.Fatalf("export %d: %v", i, err)
}
last = exported
}
again, err := reg.export(now, targets[len(targets)-1], domain.Authorization{AuthKeyID: targets[len(targets)-1].authKeyID}, nil)
if err != nil {
t.Fatalf("export existing target: %v", err)
}
if !bytes.Equal(again.token, last.token) {
t.Fatal("existing target token rotated while registry was full")
}
if _, err := reg.export(now, loginTokenTarget{rawAuthKeyID: [8]byte{0xfe}, authKeyID: [8]byte{0xfd}, sessionID: 999999}, domain.Authorization{AuthKeyID: [8]byte{0xfd}}, nil); err != nil {
t.Fatalf("export over capacity: %v", err)
}
reg.mu.Lock()
got := len(reg.byToken)
reg.mu.Unlock()
if got > loginTokenMaxRecords {
t.Fatalf("registry size = %d, want <= %d", got, loginTokenMaxRecords)
}
}

389
internal/rpc/bots.go Normal file
View file

@ -0,0 +1,389 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// registerBots 注册 bots.* RPC handler。
//
// 权限语义(官方访问矩阵):
// - setBotCommands/getBotCommands/resetBotCommands/setBotMenuButton/getBotMenuButton
// 仅 bot 自己(调用者必须是 bot 账号)。
// - setBotInfo/getBotInfoowner 经 bot:InputUser 代设,或 bot 自己(不带 bot 参数)。
//
// P2 范围:仅 default scope、单语言非 default scope 与非空 lang_code 一律接受
// 但不存储,避免覆盖全局——见各 handler 的 isDefaultBotCommandScope/lang_code 闸门);
// menu button 为 per-bot 全局per-user 维度记 todo。元数据写入由 service 在事务内
// bump bot_info_version命令变更后 bots_hooks.PushBotCommandsChanged 给在线相关用户
// 推 updateBotCommands扇出封顶 100无 pts离线/超界用户靠 version bump 在下次
// getFullUser 重拉兜底。
func (r *Router) registerBots(d *tg.ServerDispatcher) {
d.OnBotsSendCustomRequest(r.onBotsSendCustomRequest)
d.OnBotsAnswerWebhookJSONQuery(r.onBotsAnswerWebhookJSONQuery)
d.OnBotsSetBotBroadcastDefaultAdminRights(r.onBotsSetBotBroadcastDefaultAdminRights)
d.OnBotsSetBotGroupDefaultAdminRights(r.onBotsSetBotGroupDefaultAdminRights)
d.OnBotsSetBotCommands(r.onBotsSetBotCommands)
d.OnBotsResetBotCommands(r.onBotsResetBotCommands)
d.OnBotsGetBotCommands(r.onBotsGetBotCommands)
d.OnBotsSetBotInfo(r.onBotsSetBotInfo)
d.OnBotsGetBotInfo(r.onBotsGetBotInfo)
d.OnBotsSetBotMenuButton(r.onBotsSetBotMenuButton)
d.OnBotsGetBotMenuButton(r.onBotsGetBotMenuButton)
d.OnBotsReorderUsernames(r.onBotsReorderUsernames)
d.OnBotsToggleUsername(r.onBotsToggleUsername)
d.OnBotsCanSendMessage(r.onBotsCanSendMessage)
d.OnBotsAllowSendMessage(r.onBotsAllowSendMessage)
d.OnBotsInvokeWebViewCustomMethod(r.onBotsInvokeWebViewCustomMethod)
d.OnBotsGetPopularAppBots(r.onBotsGetPopularAppBots)
d.OnBotsAddPreviewMedia(r.onBotsAddPreviewMedia)
d.OnBotsEditPreviewMedia(r.onBotsEditPreviewMedia)
d.OnBotsDeletePreviewMedia(r.onBotsDeletePreviewMedia)
d.OnBotsReorderPreviewMedias(r.onBotsReorderPreviewMedias)
d.OnBotsGetPreviewInfo(r.onBotsGetPreviewInfo)
d.OnBotsGetPreviewMedias(r.onBotsGetPreviewMedias)
d.OnBotsUpdateUserEmojiStatus(r.onBotsUpdateUserEmojiStatus)
d.OnBotsToggleUserEmojiStatusPermission(r.onBotsToggleUserEmojiStatusPermission)
d.OnBotsCheckDownloadFileParams(r.onBotsCheckDownloadFileParams)
d.OnBotsGetAdminedBots(r.onBotsGetAdminedBots)
d.OnBotsUpdateStarRefProgram(r.onBotsUpdateStarRefProgram)
d.OnBotsSetCustomVerification(r.onBotsSetCustomVerification)
d.OnBotsGetBotRecommendations(r.onBotsGetBotRecommendations)
d.OnBotsCheckUsername(r.onBotsCheckUsername)
d.OnBotsCreateBot(r.onBotsCreateBot)
d.OnBotsExportBotToken(r.onBotsExportBotToken)
d.OnBotsRequestWebViewButton(r.onBotsRequestWebViewButton)
d.OnBotsGetRequestedWebViewButton(r.onBotsGetRequestedWebViewButton)
d.OnBotsGetAccessSettings(r.onBotsGetAccessSettings)
d.OnBotsEditAccessSettings(r.onBotsEditAccessSettings)
// P3startBot 深链 + inline callback 闭环。
d.OnMessagesStartBot(r.onMessagesStartBot)
d.OnMessagesGetBotCallbackAnswer(r.onMessagesGetBotCallbackAnswer)
d.OnMessagesSetBotCallbackAnswer(r.onMessagesSetBotCallbackAnswer)
d.OnMessagesGetInlineBotResults(r.onMessagesGetInlineBotResults)
d.OnMessagesSetInlineBotResults(r.onMessagesSetInlineBotResults)
d.OnMessagesSendInlineBotResult(r.onMessagesSendInlineBotResult)
d.OnMessagesSavePreparedInlineMessage(r.onMessagesSavePreparedInlineMessage)
d.OnMessagesEditInlineBotMessage(r.onMessagesEditInlineBotMessage)
d.OnMessagesSetBotShippingResults(r.onMessagesSetBotShippingResults)
d.OnMessagesSetBotPrecheckoutResults(r.onMessagesSetBotPrecheckoutResults)
}
// callerBotID 校验调用者本身是 bot 账号,返回其 user_idbot-only RPC 用)。
func (r *Router) callerBotID(ctx context.Context) (int64, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, internalErr()
}
if r.deps.Bots == nil || userID == 0 {
return 0, userBotRequiredErr()
}
if _, found, err := r.deps.Bots.BotInfo(ctx, userID); err != nil {
return 0, internalErr()
} else if !found {
return 0, userBotRequiredErr()
}
return userID, nil
}
func (r *Router) onBotsGetAdminedBots(ctx context.Context) ([]tg.UserClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Bots == nil {
return []tg.UserClass{}, nil
}
bots, err := r.deps.Bots.ListOwnedBots(ctx, userID)
if err != nil {
return nil, internalErr()
}
return r.tgUsersForViewer(userID, bots), nil
}
func (r *Router) onBotsCheckUsername(ctx context.Context, username string) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if r.deps.Bots == nil {
return false, internalErr()
}
ok, err := r.deps.Bots.CheckUsername(ctx, userID, username)
if err != nil {
return false, botUsernameErr(err)
}
return ok, nil
}
func (r *Router) onBotsCreateBot(ctx context.Context, req *tg.BotsCreateBotRequest) (tg.UserClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Bots == nil {
return nil, internalErr()
}
if err := r.validateBotManager(ctx, userID, req.ManagerID); err != nil {
return nil, err
}
u, _, err := r.deps.Bots.CreateBot(ctx, userID, req.Name, req.Username)
if err != nil {
return nil, createBotErr(err)
}
return r.tgUser(u), nil
}
func (r *Router) validateBotManager(ctx context.Context, currentUserID int64, manager tg.InputUserClass) error {
if manager == nil || r.deps.Bots == nil {
return managerPermissionMissingErr()
}
u, found, err := r.userFromInput(ctx, currentUserID, manager)
if err != nil || !found {
return managerPermissionMissingErr()
}
if _, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err != nil {
return internalErr()
} else if !found {
return managerPermissionMissingErr()
}
return nil
}
func (r *Router) onBotsExportBotToken(ctx context.Context, req *tg.BotsExportBotTokenRequest) (*tg.BotsExportedBotToken, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Bots == nil {
return nil, internalErr()
}
target, found, err := r.userFromInput(ctx, userID, req.Bot)
if err != nil || !found {
return nil, botInvalidErr()
}
token, err := r.deps.Bots.ExportBotToken(ctx, userID, target.ID, req.Revoke)
if err != nil {
if errors.Is(err, domain.ErrBotSessionsNotRevoked) && token != "" {
return &tg.BotsExportedBotToken{Token: token}, nil
}
return nil, exportBotTokenErr(err)
}
return &tg.BotsExportedBotToken{Token: token}, nil
}
// isDefaultCommandsTarget 报告请求是否落在 P2 唯一持久化的桶default scope +
// 空 lang_code。非 default scope 或非空 lang_code 一律接受但不存储——否则会
// 用某语言/某 scope 的命令覆盖唯一的全局 default 桶reset 还会误清全局)。
func isDefaultCommandsTarget(scope tg.BotCommandScopeClass, langCode string) bool {
return isDefaultBotCommandScope(scope) && langCode == ""
}
func (r *Router) onBotsSetBotCommands(ctx context.Context, req *tg.BotsSetBotCommandsRequest) (bool, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
// P2 仅持久化 default scope + 空 lang_code其它接受但不存储记 todo避免
// 覆盖全局桶,也避免客户端因 error 重试。
if !isDefaultCommandsTarget(req.Scope, req.LangCode) {
return true, nil
}
if _, err := r.deps.Bots.SetBotCommands(ctx, botID, domainBotCommands(req.Commands)); err != nil {
return false, setBotCommandsErr(err)
}
r.invalidateChannelFullBotInfoCache()
r.invalidateRPCProjectionForUser(botID)
return true, nil
}
func (r *Router) onBotsResetBotCommands(ctx context.Context, req *tg.BotsResetBotCommandsRequest) (bool, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
if !isDefaultCommandsTarget(req.Scope, req.LangCode) {
return true, nil
}
if _, err := r.deps.Bots.SetBotCommands(ctx, botID, nil); err != nil {
return false, setBotCommandsErr(err)
}
r.invalidateChannelFullBotInfoCache()
r.invalidateRPCProjectionForUser(botID)
return true, nil
}
func (r *Router) onBotsGetBotCommands(ctx context.Context, req *tg.BotsGetBotCommandsRequest) ([]tg.BotCommand, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return nil, err
}
if !isDefaultCommandsTarget(req.Scope, req.LangCode) {
return []tg.BotCommand{}, nil
}
commands, err := r.deps.Bots.GetBotCommands(ctx, botID)
if err != nil {
return nil, internalErr()
}
return tgBotCommands(commands), nil
}
// resolveBotInfoTarget 解析 setBotInfo/getBotInfo 的目标 bot带 bot 参数→owner 校验
// 该 bot不带→调用者本身须为 bot。
func (r *Router) resolveBotInfoTarget(ctx context.Context, bot tg.InputUserClass, hasBot bool) (int64, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, internalErr()
}
if r.deps.Bots == nil {
return 0, userBotInvalidErr()
}
if hasBot && bot != nil {
target, found, err := r.userFromInput(ctx, userID, bot)
if err != nil || !found {
return 0, botInvalidErr()
}
owns, err := r.deps.Bots.OwnsBot(ctx, userID, target.ID)
if err != nil {
return 0, internalErr()
}
if !owns {
return 0, botInvalidErr()
}
return target.ID, nil
}
// 无 bot 参数:调用者必须是 bot 自己。
if _, found, err := r.deps.Bots.BotInfo(ctx, userID); err != nil {
return 0, internalErr()
} else if !found {
return 0, userBotInvalidErr()
}
return userID, nil
}
func (r *Router) onBotsSetBotInfo(ctx context.Context, req *tg.BotsSetBotInfoRequest) (bool, error) {
bot, hasBot := req.GetBot()
botID, err := r.resolveBotInfoTarget(ctx, bot, hasBot)
if err != nil {
return false, err
}
// 非空 lang_code本地化 name/about/description 接受但不存储(否则写穿全局列)。
if req.LangCode != "" {
return true, nil
}
var upd domain.BotInfoUpdate
if name, ok := req.GetName(); ok {
upd.SetName, upd.Name = true, name
}
if about, ok := req.GetAbout(); ok {
upd.SetAbout, upd.About = true, about
}
if description, ok := req.GetDescription(); ok {
upd.SetDescription, upd.Description = true, description
}
if !upd.SetName && !upd.SetAbout && !upd.SetDescription {
return true, nil
}
if _, err := r.deps.Bots.SetBotInfo(ctx, botID, upd); err != nil {
return false, setBotInfoErr(err)
}
r.invalidateChannelFullBotInfoCache()
r.invalidateRPCProjectionForUser(botID)
return true, nil
}
func (r *Router) onBotsGetBotInfo(ctx context.Context, req *tg.BotsGetBotInfoRequest) (*tg.BotsBotInfo, error) {
bot, hasBot := req.GetBot()
botID, err := r.resolveBotInfoTarget(ctx, bot, hasBot)
if err != nil {
return nil, err
}
name, about, description, err := r.deps.Bots.GetBotInfo(ctx, botID)
if err != nil {
return nil, internalErr()
}
return &tg.BotsBotInfo{Name: name, About: about, Description: description}, nil
}
func (r *Router) onBotsSetBotMenuButton(ctx context.Context, req *tg.BotsSetBotMenuButtonRequest) (bool, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
button, err := domainBotMenuButton(req.Button)
if err != nil {
return false, err
}
if _, err := r.deps.Bots.SetBotMenuButton(ctx, botID, button); err != nil {
return false, setBotMenuButtonErr(err)
}
r.invalidateChannelFullBotInfoCache()
r.invalidateRPCProjectionForUser(botID)
return true, nil
}
func (r *Router) onBotsGetBotMenuButton(ctx context.Context, userid tg.InputUserClass) (tg.BotMenuButtonClass, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return nil, err
}
button, err := r.deps.Bots.GetBotMenuButton(ctx, botID)
if err != nil {
return nil, internalErr()
}
return tgBotMenuButton(button), nil
}
// --- tg ↔ domain 转换 ---
func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool {
if scope == nil {
return true
}
_, ok := scope.(*tg.BotCommandScopeDefault)
return ok
}
func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
out := make([]domain.BotCommand, 0, len(in))
for _, c := range in {
out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description})
}
return out
}
func tgBotCommands(in []domain.BotCommand) []tg.BotCommand {
out := make([]tg.BotCommand, 0, len(in))
for _, c := range in {
out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description})
}
return out
}
func domainBotMenuButton(in tg.BotMenuButtonClass) (domain.BotMenuButton, error) {
switch v := in.(type) {
case *tg.BotMenuButtonDefault:
return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil
case *tg.BotMenuButtonCommands:
return domain.BotMenuButton{Type: domain.BotMenuButtonCommands}, nil
case *tg.BotMenuButton:
return domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: v.Text, URL: v.URL}, nil
default:
return domain.BotMenuButton{}, botMenuButtonInvalidErr()
}
}
func tgBotMenuButton(b domain.BotMenuButton) tg.BotMenuButtonClass {
switch b.Type {
case domain.BotMenuButtonCommands:
return &tg.BotMenuButtonCommands{}
case domain.BotMenuButtonWebView:
return &tg.BotMenuButton{Text: b.Text, URL: b.URL}
default:
return &tg.BotMenuButtonDefault{}
}
}

View file

@ -0,0 +1,123 @@
package rpc
import (
"context"
"time"
"unicode/utf8"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"telesrv/internal/domain"
)
// botCallbackTimeout 是 getBotCallbackAnswer 的挂起上限bot 未在窗口内
// setBotCallbackAnswer 即回 BOT_RESPONSE_TIMEOUT不快速失败给 bot 上线追答的窗口)。
const botCallbackTimeout = 25 * time.Second
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery
// 推给 bot挂起等待 bot 的 setBotCallbackAnswer或超时回 BOT_RESPONSE_TIMEOUT。
func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, peerIDInvalidErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
// callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。
if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) {
return nil, dataInvalidErr()
}
botUserID := peer.ID
// game 按钮getBotCallbackAnswer.gameP3 不支持:返回空答案(客户端不弹任何东西),
// 不挂起、不推送(避免给 bot 投递无法处理的 game query
if req.Game {
return &tg.MessagesBotCallbackAnswer{}, nil
}
data, hasData := req.GetData()
if !hasData {
return nil, dataInvalidErr()
}
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID)
if err != nil {
return nil, internalErr()
}
if !ok || msg.Peer != peer {
return nil, messageIDInvalidErr()
}
queryID, pending := r.callbacks.register(botUserID, userID)
defer r.callbacks.deregister(queryID)
// updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference仅在线推给
// botbot 离线则投递 0但仍走超时窗口I5给 bot 上线追答机会)。
// MsgID 透传请求者侧的 box idP3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo
update := &tg.UpdateBotCallbackQuery{
QueryID: queryID,
UserID: userID,
Peer: &tg.PeerUser{UserID: userID},
MsgID: req.MsgID,
ChatInstance: chatInstanceFor(botUserID, userID),
}
update.SetData(data)
r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(r.clock.Now().Unix()),
})
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
defer cancel()
select {
case ans := <-pending.ch:
return tgBotCallbackAnswer(ans), nil
case <-waitCtx.Done():
return nil, botResponseTimeoutErr()
}
}
// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
// getBotCallbackAnswer。仅属主 bot 可解挂callerBotID==pending.botUserIDI6
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
ans := domain.BotCallbackAnswer{Alert: req.Alert, CacheTime: req.CacheTime}
if msg, ok := req.GetMessage(); ok {
if utf8.RuneCountInString(msg) > domain.MaxBotCallbackAnswerLen {
return false, messageTooLongErr()
}
ans.Message = msg
}
if url, ok := req.GetURL(); ok {
ans.URL = url
}
// resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
// 被等待者接收无关紧要(官方恒返回 true但非属主必须拒绝投递防钓鱼弹窗
r.callbacks.resolve(botID, req.QueryID, ans)
return true, nil
}
func tgBotCallbackAnswer(ans domain.BotCallbackAnswer) *tg.MessagesBotCallbackAnswer {
out := &tg.MessagesBotCallbackAnswer{Alert: ans.Alert, CacheTime: ans.CacheTime}
if ans.Message != "" {
out.SetMessage(ans.Message)
}
if ans.URL != "" {
out.SetURL(ans.URL)
out.HasURL = true
}
return out
}

View file

@ -0,0 +1,89 @@
package rpc
import (
"errors"
"github.com/gotd/td/tgerr"
"telesrv/internal/domain"
)
// bots.* 管理 RPC 的错误码(与 errors.go 同惯例,独立成文件便于 bot 模块维护)。
func userBotRequiredErr() error { return tgerr.New(400, "USER_BOT_REQUIRED") }
func userBotInvalidErr() error { return tgerr.New(400, "USER_BOT_INVALID") }
func botInvalidErr() error { return tgerr.New(400, "BOT_INVALID") }
func botAppBotInvalidErr() error { return tgerr.New(400, "BOT_APP_BOT_INVALID") }
func botAppInvalidErr() error { return tgerr.New(400, "BOT_APP_INVALID") }
func botAppShortNameInvalidErr() error {
return tgerr.New(400, "BOT_APP_SHORTNAME_INVALID")
}
func botCommandInvalidErr() error { return tgerr.New(400, "BOT_COMMAND_INVALID") }
func botMenuButtonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
func botCreateLimitExceededErr() error {
return tgerr.New(400, "BOT_CREATE_LIMIT_EXCEEDED")
}
func managerPermissionMissingErr() error { return tgerr.New(400, "MANAGER_PERMISSION_MISSING") }
func methodInvalidErr() error { return tgerr.New(400, "METHOD_INVALID") }
func rightsNotModifiedErr() error { return tgerr.New(400, "RIGHTS_NOT_MODIFIED") }
func botVerifierForbiddenErr() error { return tgerr.New(403, "BOT_VERIFIER_FORBIDDEN") }
func userPermissionDeniedErr() error { return tgerr.New(403, "USER_PERMISSION_DENIED") }
func setBotCommandsErr(err error) error {
if errors.Is(err, domain.ErrBotCommandInvalid) {
return botCommandInvalidErr()
}
if errors.Is(err, domain.ErrBotNotFound) {
return userBotRequiredErr()
}
return internalErr()
}
func setBotInfoErr(err error) error {
if errors.Is(err, domain.ErrBotInfoInvalid) || errors.Is(err, domain.ErrBotNotFound) {
return botInvalidErr()
}
return internalErr()
}
func setBotMenuButtonErr(err error) error {
if errors.Is(err, domain.ErrBotMenuButtonInvalid) {
return botMenuButtonInvalidErr()
}
if errors.Is(err, domain.ErrBotNotFound) {
return userBotRequiredErr()
}
return internalErr()
}
func botUsernameErr(err error) error {
switch {
case errors.Is(err, domain.ErrBotUsernameInvalid):
return usernameInvalidErr()
case errors.Is(err, domain.ErrUsernameOccupied):
return usernameOccupiedErr()
default:
return internalErr()
}
}
func createBotErr(err error) error {
switch {
case errors.Is(err, domain.ErrBotUsernameInvalid):
return usernameInvalidErr()
case errors.Is(err, domain.ErrUsernameOccupied):
return usernameOccupiedErr()
case errors.Is(err, domain.ErrBotsTooMany):
return botCreateLimitExceededErr()
case errors.Is(err, domain.ErrBotNameInvalid):
return firstNameInvalidErr()
default:
return internalErr()
}
}
func exportBotTokenErr(err error) error {
if errors.Is(err, domain.ErrBotNotFound) {
return botInvalidErr()
}
return internalErr()
}

View file

@ -0,0 +1,267 @@
package rpc
import (
"context"
"strings"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"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"
)
func TestGroupBotRPCShape(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
botStore := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
bots := botsapp.NewService(users, botStore, messages)
channelStore := memory.NewChannelStore()
baseChannels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
channels := &countingBotParticipantsChannelsService{Service: baseChannels}
owner, err := users.Create(ctx, domain.User{AccessHash: 6201, Phone: "15550006201", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := users.Create(ctx, domain.User{AccessHash: 6202, Phone: "15550006202", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
bot, _, err := bots.CreateBot(ctx, owner.ID, "Group Bot", "group_shape_bot")
if err != nil {
t.Fatalf("create bot: %v", err)
}
if _, err := bots.SetJoinGroups(ctx, bot.ID, false); err != nil {
t.Fatalf("disable join groups: %v", err)
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Channels: channels,
Bots: bots,
}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
created, err := r.onMessagesCreateChat(ownerCtx, &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Bot Group RPC",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
blockedUser := r.tgUser(bot)
if !blockedUser.BotNochats {
t.Fatalf("tg user bot_nochats = false, want true before invite")
}
if _, err := r.onChannelsInviteToChannel(ownerCtx, &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash}},
}); err == nil || !strings.Contains(err.Error(), "BOT_GROUPS_BLOCKED") {
t.Fatalf("invite blocked bot err = %v, want BOT_GROUPS_BLOCKED", err)
}
if _, err := bots.SetJoinGroups(ctx, bot.ID, true); err != nil {
t.Fatalf("enable join groups: %v", err)
}
if _, err := bots.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "status", Description: "Show status"}}); err != nil {
t.Fatalf("set bot commands: %v", err)
}
if _, err := r.onChannelsInviteToChannel(ownerCtx, &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash}},
}); err != nil {
t.Fatalf("invite allowed bot: %v", err)
}
participants, err := r.onChannelsGetParticipants(ownerCtx, &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsBots{},
Limit: 20,
})
if err != nil {
t.Fatalf("get bot participants: %v", err)
}
list := participants.(*tg.ChannelsChannelParticipants)
if list.Count != 1 || len(list.Participants) != 1 {
t.Fatalf("bot participants = count %d len %d, want one", list.Count, len(list.Participants))
}
if len(list.Users) != 1 {
t.Fatalf("bot participants users = %d, want one", len(list.Users))
}
listBot := list.Users[0].(*tg.User)
if !listBot.Bot || listBot.ID != bot.ID || listBot.BotNochats {
t.Fatalf("participants user = %+v, want allowed bot", listBot)
}
channels.botParticipantCalls = 0
full, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel: %v", err)
}
channelFull := full.FullChat.(*tg.ChannelFull)
if len(channelFull.BotInfo) != 1 || channelFull.BotInfo[0].UserID != bot.ID {
t.Fatalf("channel full bot_info = %+v, want bot %d", channelFull.BotInfo, bot.ID)
}
if len(channelFull.BotInfo[0].Commands) != 1 || channelFull.BotInfo[0].Commands[0].Command != "status" {
t.Fatalf("channel full bot commands = %+v, want status", channelFull.BotInfo[0].Commands)
}
foundUser := false
for _, u := range full.Users {
if got, ok := u.(*tg.User); ok && got.ID == bot.ID && got.Bot {
foundUser = true
}
}
if !foundUser {
t.Fatalf("full channel users = %+v, want bot user", full.Users)
}
if _, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}); err != nil {
t.Fatalf("get full channel cached: %v", err)
}
if channels.botParticipantCalls != 1 {
t.Fatalf("full channel bot participants calls = %d, want 1", channels.botParticipantCalls)
}
}
func TestFullChannelBotInfoCacheCachesEmptyResult(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
botStore := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
bots := botsapp.NewService(users, botStore, messages)
channelStore := memory.NewChannelStore()
baseChannels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
channels := &countingBotParticipantsChannelsService{Service: baseChannels}
owner, err := users.Create(ctx, domain.User{AccessHash: 6221, Phone: "15550006221", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := users.Create(ctx, domain.User{AccessHash: 6222, Phone: "15550006222", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Channels: channels,
Bots: bots,
}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
created, err := r.onMessagesCreateChat(ownerCtx, &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Empty Bot Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
channels.botParticipantCalls = 0
first, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get first full channel: %v", err)
}
if got := len(first.FullChat.(*tg.ChannelFull).BotInfo); got != 0 {
t.Fatalf("first full channel bot_info len = %d, want 0", got)
}
second, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get second full channel: %v", err)
}
if got := len(second.FullChat.(*tg.ChannelFull).BotInfo); got != 0 {
t.Fatalf("second full channel bot_info len = %d, want 0", got)
}
if channels.botParticipantCalls != 1 {
t.Fatalf("empty full channel bot participants calls = %d, want 1", channels.botParticipantCalls)
}
}
func TestTGUsersForIDsUsesBatchBotProfiles(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
botStore := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
baseBots := botsapp.NewService(users, botStore, messages)
bots := &countingBatchBotsService{Service: baseBots}
owner, err := users.Create(ctx, domain.User{AccessHash: 6211, Phone: "15550006211", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
botA, _, err := baseBots.CreateBot(ctx, owner.ID, "Batch Bot A", "batcha_bot")
if err != nil {
t.Fatalf("create bot A: %v", err)
}
botB, _, err := baseBots.CreateBot(ctx, owner.ID, "Batch Bot B", "batchb_bot")
if err != nil {
t.Fatalf("create bot B: %v", err)
}
if _, err := baseBots.SetJoinGroups(ctx, botA.ID, false); err != nil {
t.Fatalf("disable bot A groups: %v", err)
}
if _, err := baseBots.SetInlinePlaceholder(ctx, botB.ID, "Search B"); err != nil {
t.Fatalf("set bot B inline placeholder: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(users),
Bots: bots,
}, zaptest.NewLogger(t), clock.System)
got := r.tgUsersForIDs(ctx, owner.ID, []int64{botA.ID, botB.ID})
if bots.batchCalls != 1 {
t.Fatalf("BotInfos calls = %d, want 1", bots.batchCalls)
}
if bots.singleCalls != 0 {
t.Fatalf("BotInfo calls = %d, want 0", bots.singleCalls)
}
byID := make(map[int64]*tg.User)
for _, item := range got {
if u, ok := item.(*tg.User); ok {
byID[u.ID] = u
}
}
if u := byID[botA.ID]; u == nil || !u.BotNochats {
t.Fatalf("bot A user = %+v, want bot_nochats", u)
}
if u := byID[botB.ID]; u == nil || u.BotInlinePlaceholder != "Search B" {
t.Fatalf("bot B user = %+v, want inline placeholder", u)
}
}
type countingBatchBotsService struct {
*botsapp.Service
singleCalls int
batchCalls int
}
func (s *countingBatchBotsService) BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
s.singleCalls++
return s.Service.BotInfo(ctx, botUserID)
}
func (s *countingBatchBotsService) BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
s.batchCalls++
return s.Service.BotInfos(ctx, botUserIDs)
}
type countingBotParticipantsChannelsService struct {
*appchannels.Service
botParticipantCalls int
}
func (s *countingBotParticipantsChannelsService) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
if filter.Kind == domain.ChannelParticipantsBots {
s.botParticipantCalls++
}
return s.Service.GetParticipants(ctx, userID, channelID, filter, offset, limit)
}

102
internal/rpc/bots_hooks.go Normal file
View file

@ -0,0 +1,102 @@
package rpc
import (
"context"
"time"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// 本文件实现 app/bots 的 RouterHooks 回调token revoke 后的 session 失效闭环,
// 以及命令变更后的 updateBotCommands 在线推送。Router 创建后经
// botsService.SetRouterHooks(router) 装配(见 cmd/telesrv/main.go
// maxBotCommandsPushPeers 限制单次命令变更的推送扇出bot 的最近 dialog peer 数)。
// 超出的离线/长尾用户靠 bot_info_version bump 在下次 getFullUser 时拿到新命令。
const maxBotCommandsPushPeers = 100
// RevokeBotSessions 撤销 bot 的全部已登录 session删除全部 authorization 行并
// 强制断开在线连接(与 account.resetAuthorization 被踢闭环同款顺序)。
func (r *Router) RevokeBotSessions(ctx context.Context, botUserID int64) error {
if r.deps.Auth == nil || botUserID == 0 {
return nil
}
deleted, err := r.deps.Auth.ResetAuthorizations(ctx, botUserID, [8]byte{})
if err != nil {
return err
}
for _, a := range deleted {
r.revokeAuthKeySessions(a.AuthKeyID)
if err := r.clearAuthKeyState(ctx, a.AuthKeyID); err != nil {
r.log.Warn("revoke bot sessions: clear auth key state",
zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
}
if len(deleted) > 0 {
r.log.Info("revoked bot sessions", zap.Int64("bot_user_id", botUserID), zap.Int("count", len(deleted)))
}
return nil
}
// PushBotCommandsChanged 给「与该 bot 有私聊 dialog 且在线」的用户推
// updateBotCommandspeer = 该 bot 的 user peer对齐 TDesktop/DrKLO 消费语义)。
// updateBotCommands 无 pts/qts不进 getDifference——离线用户由随写库一起完成的
// bot_info_version bump 兜底(下次 getFullUser 重拉命令)。
//
// fire-and-forget在独立 goroutine 内执行(脱离已返回的 setBotCommands RPC ctx
// 否则 GetDialogs + 最多 maxBotCommandsPushPeers 次 best-effort 推送会把 RPC 响应
// 拖到拥塞超时之和、并跨用户占住 BotFather 条带锁。推送是纯通知,丢失靠 version
// bump 兜底,不需保证投达。扇出有界:只取 bot dialog 列表前 maxBotCommandsPushPeers
// 个 user peer再按在线快照过滤超界部分走版本兜底有意取舍
func (r *Router) PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) {
if r.deps.Dialogs == nil || r.deps.Sessions == nil || botUserID == 0 {
return
}
// 拷贝命令切片调用方service可能复用底层数组。
cmds := append([]domain.BotCommand(nil), commands...)
go r.pushBotCommandsChanged(context.WithoutCancel(ctx), botUserID, cmds)
}
func (r *Router) pushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) {
defer func() {
if rec := recover(); rec != nil {
r.log.Error("push bot commands panicked", zap.Int64("bot_user_id", botUserID), zap.Any("panic", rec))
}
}()
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
list, err := r.deps.Dialogs.GetDialogs(ctx, botUserID, domain.DialogFilter{Limit: maxBotCommandsPushPeers})
if err != nil {
r.log.Warn("push bot commands: list bot dialogs", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return
}
candidates := make([]int64, 0, len(list.Dialogs))
for _, dialog := range list.Dialogs {
if dialog.Peer.Type == domain.PeerTypeUser && dialog.Peer.ID != 0 && dialog.Peer.ID != botUserID {
candidates = append(candidates, dialog.Peer.ID)
}
}
if len(candidates) == 0 {
return
}
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok {
candidates = provider.OnlineUserIDsForCandidates(candidates, maxBotCommandsPushPeers)
}
if len(candidates) == 0 {
return
}
update := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateBotCommands{
Peer: &tg.PeerUser{UserID: botUserID},
BotID: botUserID,
Commands: tgBotCommands(commands),
}},
Date: int(r.clock.Now().Unix()),
}
for _, userID := range candidates {
r.pushUserMessage(ctx, userID, "push bot commands", update)
}
}

1200
internal/rpc/bots_inline.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,191 @@
package rpc
import (
"context"
"crypto/sha256"
"encoding/binary"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func inlineMessageAccessHash(botID int64, msg domain.Message) int64 {
var buf [32]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.OwnerUserID))
binary.LittleEndian.PutUint64(buf[16:24], uint64(int64(msg.ID)))
binary.LittleEndian.PutUint64(buf[24:32], uint64(msg.UID))
sum := sha256.Sum256(buf[:])
v := int64(binary.LittleEndian.Uint64(sum[:8]))
if v == 0 {
return 1
}
return v
}
func inlineChannelMessageAccessHash(botID int64, msg domain.ChannelMessage) int64 {
var buf [40]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.ChannelID))
binary.LittleEndian.PutUint64(buf[16:24], uint64(int64(msg.ID)))
binary.LittleEndian.PutUint64(buf[24:32], uint64(msg.RandomID))
binary.LittleEndian.PutUint64(buf[32:40], uint64(msg.SenderUserID))
sum := sha256.Sum256(buf[:])
v := int64(binary.LittleEndian.Uint64(sum[:8]))
if v == 0 {
return 1
}
return v
}
func (r *Router) inputInlineMessageIDForPrivateMessage(botID int64, msg domain.Message) tg.InputBotInlineMessageIDClass {
if botID == 0 || msg.OwnerUserID == 0 || msg.ID <= 0 || msg.UID == 0 || msg.ViaBotID != botID || msg.Peer.Type != domain.PeerTypeUser {
return nil
}
return &tg.InputBotInlineMessageID64{
DCID: r.cfg.DC,
OwnerID: msg.OwnerUserID,
ID: msg.ID,
AccessHash: inlineMessageAccessHash(botID, msg),
}
}
func (r *Router) inputInlineMessageIDForChannelMessage(botID int64, msg domain.ChannelMessage) tg.InputBotInlineMessageIDClass {
if botID == 0 || msg.ChannelID == 0 || msg.ID <= 0 || msg.RandomID == 0 || msg.SenderUserID == 0 || msg.ViaBotID != botID {
return nil
}
return &tg.InputBotInlineMessageID64{
DCID: r.cfg.DC,
OwnerID: msg.ChannelID,
ID: msg.ID,
AccessHash: inlineChannelMessageAccessHash(botID, msg),
}
}
func (r *Router) privateMessageFromInlineID(ctx context.Context, botID int64, id tg.InputBotInlineMessageIDClass) (domain.Message, bool, error) {
if err := validateInputBotInlineMessageID(id); err != nil {
return domain.Message{}, false, err
}
in, ok := id.(*tg.InputBotInlineMessageID64)
if !ok {
return domain.Message{}, false, nil
}
if in.DCID != 0 && in.DCID != r.cfg.DC {
return domain.Message{}, false, nil
}
msg, found, err := r.lookupOwnerMessage(ctx, in.OwnerID, in.ID)
if err != nil || !found {
return domain.Message{}, false, err
}
if msg.Peer.Type != domain.PeerTypeUser || msg.ViaBotID != botID {
return domain.Message{}, false, nil
}
if inlineMessageAccessHash(botID, msg) != in.AccessHash {
return domain.Message{}, false, nil
}
return msg, true, nil
}
func (r *Router) channelMessageFromInlineID(ctx context.Context, botID int64, id tg.InputBotInlineMessageIDClass) (domain.Channel, domain.ChannelMessage, bool, error) {
if err := validateInputBotInlineMessageID(id); err != nil {
return domain.Channel{}, domain.ChannelMessage{}, false, err
}
in, ok := id.(*tg.InputBotInlineMessageID64)
if !ok {
return domain.Channel{}, domain.ChannelMessage{}, false, nil
}
if in.DCID != 0 && in.DCID != r.cfg.DC {
return domain.Channel{}, domain.ChannelMessage{}, false, nil
}
if in.OwnerID <= 0 || r.deps.Channels == nil {
return domain.Channel{}, domain.ChannelMessage{}, false, nil
}
channel, msg, found, err := r.deps.Channels.GetInlineBotMessage(ctx, botID, in.OwnerID, in.ID)
if err != nil || !found {
return domain.Channel{}, domain.ChannelMessage{}, false, err
}
if inlineChannelMessageAccessHash(botID, msg) != in.AccessHash {
return domain.Channel{}, domain.ChannelMessage{}, false, nil
}
return channel, msg, true, nil
}
func (r *Router) privateInlineMessageIDFromSendUpdates(ctx context.Context, botID, userID int64, updates tg.UpdatesClass) tg.InputBotInlineMessageIDClass {
box, ok := updates.(*tg.Updates)
if !ok {
return nil
}
for _, update := range box.Updates {
newMessage, ok := update.(*tg.UpdateNewMessage)
if !ok {
continue
}
msg, ok := newMessage.Message.(*tg.Message)
if !ok || msg.ID <= 0 {
continue
}
domainMsg, found, err := r.lookupOwnerMessage(ctx, userID, msg.ID)
if err != nil || !found {
continue
}
if id := r.inputInlineMessageIDForPrivateMessage(botID, domainMsg); id != nil {
return id
}
}
return nil
}
func (r *Router) channelInlineMessageIDFromSendUpdates(ctx context.Context, botID, userID int64, updates tg.UpdatesClass) tg.InputBotInlineMessageIDClass {
box, ok := updates.(*tg.Updates)
if !ok || r.deps.Channels == nil {
return nil
}
for _, update := range box.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
msg, ok := newMessage.Message.(*tg.Message)
if !ok || msg.ID <= 0 {
continue
}
peer, ok := msg.PeerID.(*tg.PeerChannel)
if !ok || peer.ChannelID == 0 {
continue
}
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ChannelID, []int{msg.ID})
if err != nil || len(history.Messages) != 1 {
continue
}
if id := r.inputInlineMessageIDForChannelMessage(botID, history.Messages[0]); id != nil {
return id
}
}
return nil
}
func (r *Router) pushInlineBotSendFeedback(ctx context.Context, userID int64, results domain.BotInlineResults, result domain.BotInlineResult, updates tg.UpdatesClass) {
if results.BotUserID == 0 || result.ID == "" {
return
}
update := &tg.UpdateBotInlineSend{
UserID: userID,
Query: results.Query,
ID: result.ID,
}
if results.Geo != nil {
update.SetGeo(tgGeoPoint(*results.Geo))
}
if result.ReplyMarkup != nil && !result.ReplyMarkup.IsZero() {
if msgID := r.privateInlineMessageIDFromSendUpdates(ctx, results.BotUserID, userID, updates); msgID != nil {
update.SetMsgID(msgID)
} else if msgID := r.channelInlineMessageIDFromSendUpdates(ctx, results.BotUserID, userID, updates); msgID != nil {
update.SetMsgID(msgID)
}
}
r.pushUserMessage(ctx, results.BotUserID, "push bot inline send", &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(r.clock.Now().Unix()),
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,862 @@
package rpc
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"hash/fnv"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) resolveBotUserForViewer(ctx context.Context, viewerID int64, bot tg.InputUserClass) (domain.User, error) {
if bot == nil || r.deps.Bots == nil {
return domain.User{}, botInvalidErr()
}
u, found, err := r.userFromInput(ctx, viewerID, bot)
if err != nil || !found {
return domain.User{}, botInvalidErr()
}
if _, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err != nil {
return domain.User{}, internalErr()
} else if !found {
return domain.User{}, botInvalidErr()
}
return u, nil
}
func (r *Router) resolveOwnedBotUser(ctx context.Context, ownerID int64, bot tg.InputUserClass) (domain.User, error) {
u, err := r.resolveBotUserForViewer(ctx, ownerID, bot)
if err != nil {
return domain.User{}, err
}
owns, err := r.deps.Bots.OwnsBot(ctx, ownerID, u.ID)
if err != nil {
return domain.User{}, internalErr()
}
if !owns {
return domain.User{}, botInvalidErr()
}
return u, nil
}
func (r *Router) onBotsSendCustomRequest(ctx context.Context, req *tg.BotsSendCustomRequestRequest) (*tg.DataJSON, error) {
if _, err := r.callerBotID(ctx); err != nil {
return nil, err
}
return nil, methodInvalidErr()
}
func (r *Router) onBotsAnswerWebhookJSONQuery(ctx context.Context, req *tg.BotsAnswerWebhookJSONQueryRequest) (bool, error) {
if _, err := r.callerBotID(ctx); err != nil {
return false, err
}
return false, queryIDInvalidErr()
}
func (r *Router) onBotsSetBotBroadcastDefaultAdminRights(ctx context.Context, _ tg.ChatAdminRights) (bool, error) {
if _, err := r.callerBotID(ctx); err != nil {
return false, err
}
return false, rightsNotModifiedErr()
}
func (r *Router) onBotsSetBotGroupDefaultAdminRights(ctx context.Context, _ tg.ChatAdminRights) (bool, error) {
if _, err := r.callerBotID(ctx); err != nil {
return false, err
}
return false, rightsNotModifiedErr()
}
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 {
return false, err
}
return false, usernameNotModifiedErr()
}
func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUsernameRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
return false, err
}
return false, usernameNotModifiedErr()
}
func (r *Router) onBotsCanSendMessage(ctx context.Context, bot tg.InputUserClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
botUser, err := r.resolveBotUserForViewer(ctx, userID, bot)
if err != nil {
return false, err
}
allowed, err := r.deps.Bots.CanSendMessage(ctx, userID, botUser.ID)
if err != nil {
return false, internalErr()
}
return allowed, nil
}
func (r *Router) onBotsAllowSendMessage(ctx context.Context, bot tg.InputUserClass) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
botUser, err := r.resolveBotUserForViewer(ctx, userID, bot)
if err != nil {
return nil, err
}
if _, err := r.deps.Bots.AllowSendMessage(ctx, userID, botUser.ID, true); err != nil {
return nil, botInvalidErr()
}
res, err := r.sendBotAllowedServiceMessage(ctx, userID, botUser.ID)
if err != nil {
return nil, internalErr()
}
if res.Duplicate {
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}
users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil
}
func (r *Router) sendBotAllowedServiceMessage(ctx context.Context, userID, botUserID int64) (domain.SendPrivateTextResult, error) {
return r.sendBotAllowedServiceMessageWith(ctx, userID, botUserID, domain.MessageBotAllowedAction{FromRequest: true})
}
func (r *Router) sendBotAllowedServiceMessageWith(ctx context.Context, userID, botUserID int64, action domain.MessageBotAllowedAction) (domain.SendPrivateTextResult, error) {
if r.deps.Messages == nil {
return domain.SendPrivateTextResult{}, botInvalidErr()
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUserID)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: botUserID,
RandomID: botAllowedServiceMessageRandomID(userID, botUserID, action),
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionBotAllowed,
BotAllowed: &action,
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
}
func botAllowedServiceMessageRandomID(userID, botUserID int64, action domain.MessageBotAllowedAction) int64 {
var raw [16]byte
binary.LittleEndian.PutUint64(raw[:8], uint64(userID))
binary.LittleEndian.PutUint64(raw[8:], uint64(botUserID))
h := fnv.New64a()
_, _ = h.Write(raw[:])
if action.AttachMenu {
_, _ = h.Write([]byte("attach_menu"))
}
if action.FromRequest {
_, _ = h.Write([]byte("from_request"))
}
if action.Domain != "" {
_, _ = h.Write([]byte(action.Domain))
}
id := int64(h.Sum64() & ((uint64(1) << 63) - 1))
if id == 0 {
return -1
}
return -id
}
func (r *Router) onBotsInvokeWebViewCustomMethod(ctx context.Context, req *tg.BotsInvokeWebViewCustomMethodRequest) (*tg.DataJSON, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
if err != nil {
return nil, err
}
params := req.Params.Data
if !json.Valid([]byte(params)) {
return nil, methodInvalidErr()
}
if _, err := r.deps.Bots.PutWebViewCustomMethodQuery(ctx, bot.ID, userID, req.CustomMethod, params); err != nil {
return nil, methodInvalidErr()
}
return nil, methodInvalidErr()
}
func (r *Router) onBotsGetPopularAppBots(ctx context.Context, req *tg.BotsGetPopularAppBotsRequest) (*tg.BotsPopularAppBots, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return &tg.BotsPopularAppBots{Users: []tg.UserClass{}}, nil
}
func (r *Router) onBotsAddPreviewMedia(ctx context.Context, req *tg.BotsAddPreviewMediaRequest) (*tg.BotPreviewMedia, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return nil, err
}
app, err := r.mainBotAppForOwner(ctx, bot.ID)
if err != nil {
return nil, err
}
media, err := r.botPreviewMediaFromInput(ctx, userID, req.Media)
if err != nil {
return nil, err
}
media.BotUserID = bot.ID
media.AppID = app.ID
out, _, err := r.deps.Bots.UpsertBotAppPreviewMedia(ctx, media)
if err != nil {
return nil, botAppInvalidErr()
}
return r.tgBotPreviewMedia(ctx, out), nil
}
func (r *Router) onBotsEditPreviewMedia(ctx context.Context, req *tg.BotsEditPreviewMediaRequest) (*tg.BotPreviewMedia, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return nil, err
}
app, err := r.mainBotAppForOwner(ctx, bot.ID)
if err != nil {
return nil, err
}
current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, req.Media)
if err != nil {
return nil, err
}
next, err := r.botPreviewMediaFromInput(ctx, userID, req.NewMedia)
if err != nil {
return nil, err
}
next.ID = current.ID
next.Position = current.Position
next.BotUserID = bot.ID
next.AppID = app.ID
out, _, err := r.deps.Bots.UpsertBotAppPreviewMedia(ctx, next)
if err != nil {
return nil, botAppInvalidErr()
}
return r.tgBotPreviewMedia(ctx, out), nil
}
func (r *Router) onBotsDeletePreviewMedia(ctx context.Context, req *tg.BotsDeletePreviewMediaRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return false, err
}
app, err := r.mainBotAppForOwner(ctx, bot.ID)
if err != nil {
return false, err
}
if len(req.Media) == 0 || len(req.Media) > domain.MaxBotPreviewMedia {
return false, mediaInvalidErr()
}
for _, input := range req.Media {
current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, input)
if err != nil {
return false, err
}
if _, err := r.deps.Bots.DeleteBotAppPreviewMedia(ctx, bot.ID, app.ID, current.ID); err != nil {
return false, botAppInvalidErr()
}
}
return true, nil
}
func (r *Router) onBotsReorderPreviewMedias(ctx context.Context, req *tg.BotsReorderPreviewMediasRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
if err != nil {
return false, err
}
app, err := r.mainBotAppForOwner(ctx, bot.ID)
if err != nil {
return false, err
}
if len(req.Order) == 0 || len(req.Order) > domain.MaxBotPreviewMedia {
return false, mediaInvalidErr()
}
ids := make([]int64, 0, len(req.Order))
seen := map[int64]bool{}
for _, media := range req.Order {
current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, media)
if err != nil {
return false, err
}
if seen[current.ID] {
return false, mediaInvalidErr()
}
seen[current.ID] = true
ids = append(ids, current.ID)
}
if _, err := r.deps.Bots.ReorderBotAppPreviewMedia(ctx, bot.ID, app.ID, ids); err != nil {
return false, botAppInvalidErr()
}
return true, nil
}
func (r *Router) onBotsGetPreviewInfo(ctx context.Context, req *tg.BotsGetPreviewInfoRequest) (*tg.BotsPreviewInfo, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
if err != nil {
return nil, err
}
app, found, err := r.deps.Bots.GetMainBotApp(ctx, bot.ID)
if err != nil {
return nil, internalErr()
}
if !found {
return &tg.BotsPreviewInfo{Media: []tg.BotPreviewMedia{}, LangCodes: []string{}}, nil
}
media, err := r.botPreviewMedias(ctx, bot.ID, app.ID)
if err != nil {
return nil, err
}
langs := []string{}
if len(media) > 0 {
langs = append(langs, "")
}
return &tg.BotsPreviewInfo{Media: media, LangCodes: langs}, nil
}
func (r *Router) onBotsGetPreviewMedias(ctx context.Context, bot tg.InputUserClass) ([]tg.BotPreviewMedia, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
botUser, err := r.resolveBotUserForViewer(ctx, userID, bot)
if err != nil {
return nil, err
}
app, found, err := r.deps.Bots.GetMainBotApp(ctx, botUser.ID)
if err != nil {
return nil, internalErr()
}
if !found {
return []tg.BotPreviewMedia{}, nil
}
return r.botPreviewMedias(ctx, botUser.ID, app.ID)
}
func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUpdateUserEmojiStatusRequest) (bool, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req.UserID == nil || req.EmojiStatus == nil {
return false, userIDInvalidErr()
}
if r.deps.Bots == nil || r.deps.Users == nil {
return false, userPermissionDeniedErr()
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return false, internalErr()
}
if !found {
return false, userIDInvalidErr()
}
allowed, err := r.deps.Bots.BotEmojiStatusPermission(ctx, botID, target.ID)
if err != nil {
return false, internalErr()
}
if !allowed {
return false, userPermissionDeniedErr()
}
documentID, until, err := botEmojiStatusFromTG(req.EmojiStatus)
if err != nil {
return false, err
}
svc, ok := r.deps.Users.(UserPremiumService)
if !ok {
return false, userPermissionDeniedErr()
}
u, err := svc.UpdateEmojiStatus(ctx, target.ID, documentID, until)
if err != nil {
if errors.Is(err, domain.ErrPremiumRequired) {
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
return false, internalErr()
}
r.invalidateRPCProjectionForUser(u.ID)
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{
UserID: u.ID,
EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()),
}},
Users: []tg.UserClass{r.tgUser(u)},
Date: int(r.clock.Now().Unix()),
})
return true, nil
}
func (r *Router) onBotsToggleUserEmojiStatusPermission(ctx context.Context, req *tg.BotsToggleUserEmojiStatusPermissionRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if r.deps.Bots == nil {
return false, botInvalidErr()
}
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
if err != nil {
return false, err
}
if err := r.deps.Bots.SetBotEmojiStatusPermission(ctx, bot.ID, userID, req.Enabled); err != nil {
return false, botInvalidErr()
}
return true, nil
}
func (r *Router) onBotsCheckDownloadFileParams(ctx context.Context, req *tg.BotsCheckDownloadFileParamsRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.resolveBotUserForViewer(ctx, userID, req.Bot); err != nil {
return false, err
}
return checkBotDownloadFileParams(ctx, req.FileName, req.URL), nil
}
func (r *Router) onBotsUpdateStarRefProgram(ctx context.Context, req *tg.BotsUpdateStarRefProgramRequest) (*tg.StarRefProgram, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
return nil, err
}
return nil, botInvalidErr()
}
func (r *Router) onBotsSetCustomVerification(ctx context.Context, req *tg.BotsSetCustomVerificationRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if bot, ok := req.GetBot(); ok {
if _, err := r.resolveOwnedBotUser(ctx, userID, bot); err != nil {
return false, err
}
} else if _, err := r.callerBotID(ctx); err != nil {
return false, err
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return false, err
}
return false, botVerifierForbiddenErr()
}
func (r *Router) onBotsGetBotRecommendations(ctx context.Context, _ tg.InputUserClass) (tg.UsersUsersClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return &tg.UsersUsers{Users: []tg.UserClass{}}, nil
}
func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsRequestWebViewButtonRequest) (*tg.BotsRequestedButton, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return nil, err
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req.UserID == nil || req.Button == nil {
return nil, buttonDataInvalidErr()
}
if r.deps.Bots == nil {
return nil, buttonDataInvalidErr()
}
if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil || !found {
return nil, userIDInvalidErr()
}
button, err := domainRequestedButtonFromTG(botID, req.UserID, req.Button)
if err != nil {
return nil, err
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, userIDInvalidErr()
}
button.UserID = target.ID
saved, err := r.deps.Bots.SaveRequestedWebViewButton(ctx, button)
if err != nil {
return nil, buttonDataInvalidErr()
}
return &tg.BotsRequestedButton{WebappReqID: saved.WebAppReqID}, nil
}
func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (tg.KeyboardButtonClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
if err != nil {
return nil, err
}
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, bot.ID, userID, req.WebappReqID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, buttonDataInvalidErr()
}
return tgKeyboardButtonRequestPeer(button), nil
}
func botEmojiStatusFromTG(status tg.EmojiStatusClass) (documentID int64, until int, err error) {
switch s := status.(type) {
case *tg.EmojiStatusEmpty:
return 0, 0, nil
case *tg.EmojiStatus:
if s.DocumentID < 0 {
return 0, 0, tgerr400("EMOJI_STATUS_INVALID")
}
if v, ok := s.GetUntil(); ok {
until = v
}
return s.DocumentID, until, nil
case *tg.EmojiStatusCollectible:
if s.DocumentID < 0 {
return 0, 0, tgerr400("EMOJI_STATUS_INVALID")
}
if v, ok := s.GetUntil(); ok {
until = v
}
return s.DocumentID, until, nil
default:
return 0, 0, tgerr400("EMOJI_STATUS_INVALID")
}
}
func (r *Router) mainBotAppForOwner(ctx context.Context, botUserID int64) (domain.BotApp, error) {
app, found, err := r.deps.Bots.GetMainBotApp(ctx, botUserID)
if err != nil {
return domain.BotApp{}, internalErr()
}
if !found {
return domain.BotApp{}, botAppInvalidErr()
}
return app, nil
}
func (r *Router) botPreviewMediaFromInput(ctx context.Context, userID int64, input tg.InputMediaClass) (domain.BotAppPreviewMedia, error) {
media, err := r.resolveInputMedia(ctx, userID, input)
if err != nil {
return domain.BotAppPreviewMedia{}, err
}
if media == nil {
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
}
switch media.Kind {
case domain.MessageMediaKindPhoto:
if media.Photo == nil || media.Photo.ID == 0 {
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
}
return domain.BotAppPreviewMedia{PhotoID: media.Photo.ID}, nil
case domain.MessageMediaKindDocument:
if media.Document == nil || media.Document.ID == 0 {
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
}
return domain.BotAppPreviewMedia{DocumentID: media.Document.ID}, nil
default:
return domain.BotAppPreviewMedia{}, mediaTypeInvalidErr()
}
}
func (r *Router) botPreviewMediaIDFromInput(ctx context.Context, userID, botUserID, appID int64, input tg.InputMediaClass) (domain.BotAppPreviewMedia, error) {
target, err := r.botPreviewMediaFromInput(ctx, userID, input)
if err != nil {
return domain.BotAppPreviewMedia{}, err
}
items, err := r.deps.Bots.ListBotAppPreviewMedia(ctx, botUserID, appID)
if err != nil {
return domain.BotAppPreviewMedia{}, internalErr()
}
for _, item := range items {
if target.PhotoID != 0 && item.PhotoID == target.PhotoID {
return item, nil
}
if target.DocumentID != 0 && item.DocumentID == target.DocumentID {
return item, nil
}
}
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
}
func (r *Router) botPreviewMedias(ctx context.Context, botUserID, appID int64) ([]tg.BotPreviewMedia, error) {
items, err := r.deps.Bots.ListBotAppPreviewMedia(ctx, botUserID, appID)
if err != nil {
return nil, internalErr()
}
out := make([]tg.BotPreviewMedia, 0, len(items))
for _, item := range items {
converted := r.tgBotPreviewMedia(ctx, item)
if converted != nil {
out = append(out, *converted)
}
}
return out, nil
}
func (r *Router) tgBotPreviewMedia(ctx context.Context, item domain.BotAppPreviewMedia) *tg.BotPreviewMedia {
out := &tg.BotPreviewMedia{Date: int(r.clock.Now().Unix())}
switch {
case item.PhotoID != 0:
var photo tg.PhotoClass = &tg.PhotoEmpty{ID: item.PhotoID}
if r.deps.Files != nil {
if p, found, err := r.deps.Files.GetPhoto(ctx, item.PhotoID); err == nil && found {
photo = tgPhoto(p)
}
}
out.Media = &tg.MessageMediaPhoto{Photo: photo}
case item.DocumentID != 0:
var doc tg.DocumentClass = &tg.DocumentEmpty{ID: item.DocumentID}
if r.deps.Files != nil {
if d, found, err := r.deps.Files.GetDocument(ctx, item.DocumentID); err == nil && found {
doc = tgDocument(d)
}
}
out.Media = &tg.MessageMediaDocument{Document: doc}
default:
out.Media = &tg.MessageMediaEmpty{}
}
return out
}
func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButtonClass) (domain.BotRequestedWebViewButton, error) {
var out domain.BotRequestedWebViewButton
out.BotUserID = botUserID
switch b := button.(type) {
case *tg.InputKeyboardButtonRequestPeer:
out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text)
out.PeerType = requestPeerTypeName(b.PeerType)
out.MaxQuantity = b.MaxQuantity
case *tg.KeyboardButtonRequestPeer:
out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text)
out.PeerType = requestPeerTypeName(b.PeerType)
out.MaxQuantity = b.MaxQuantity
default:
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
if out.ButtonID == 0 || out.Text == "" || out.PeerType == "" {
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
return out, nil
}
func requestPeerTypeName(peerType tg.RequestPeerTypeClass) string {
switch peerType.(type) {
case *tg.RequestPeerTypeUser:
return "user"
case *tg.RequestPeerTypeChat:
return "chat"
case *tg.RequestPeerTypeBroadcast:
return "broadcast"
default:
return ""
}
}
func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.KeyboardButtonClass {
return &tg.KeyboardButtonRequestPeer{
Text: button.Text,
ButtonID: button.ButtonID,
PeerType: tgRequestPeerType(button.PeerType),
MaxQuantity: button.MaxQuantity,
}
}
func tgRequestPeerType(kind string) tg.RequestPeerTypeClass {
switch kind {
case "chat":
return &tg.RequestPeerTypeChat{}
case "broadcast":
return &tg.RequestPeerTypeBroadcast{}
default:
return &tg.RequestPeerTypeUser{}
}
}
const maxBotDownloadBytes = 25 << 20
func checkBotDownloadFileParams(ctx context.Context, fileName, rawURL string) bool {
fileName = strings.TrimSpace(fileName)
if fileName == "" || len(fileName) > 128 || filepath.Base(fileName) != fileName {
return false
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || len(rawURL) > domain.MaxBotAppURLLen {
return false
}
if !downloadURLHostAllowed(ctx, parsed) || !downloadExtensionAllowed(fileName, parsed.Path) {
return false
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, parsed.String(), nil)
if err != nil {
return false
}
client := &http.Client{
Timeout: 3 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 || req.URL == nil || req.URL.Scheme != "https" || !downloadURLHostAllowed(req.Context(), req.URL) {
return http.ErrUseLastResponse
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return false
}
if resp.ContentLength > maxBotDownloadBytes {
return false
}
if ct := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])); ct != "" && !downloadMIMEAllowed(ct) {
return false
}
return true
}
func downloadURLHostAllowed(ctx context.Context, u *url.URL) bool {
host := u.Hostname()
if host == "" || strings.EqualFold(host, "localhost") {
return false
}
if ip := net.ParseIP(host); ip != nil {
return publicDownloadIP(ip)
}
lookupCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
if err != nil || len(addrs) == 0 {
return false
}
for _, addr := range addrs {
if !publicDownloadIP(addr.IP) {
return false
}
}
return true
}
func publicDownloadIP(ip net.IP) bool {
if ip == nil {
return false
}
return !(ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast())
}
func downloadExtensionAllowed(fileName, urlPath string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
if ext == "" {
ext = strings.ToLower(filepath.Ext(urlPath))
}
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".pdf", ".txt", ".json", ".zip":
return true
default:
return false
}
}
func downloadMIMEAllowed(mime string) bool {
if strings.HasPrefix(mime, "image/") || strings.HasPrefix(mime, "video/") {
return true
}
switch mime {
case "application/pdf", "application/json", "application/zip", "application/octet-stream", "text/plain":
return true
default:
return false
}
}
func (r *Router) onBotsGetAccessSettings(ctx context.Context, bot tg.InputUserClass) (*tg.BotsAccessSettings, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.resolveBotUserForViewer(ctx, userID, bot); err != nil {
return nil, err
}
return &tg.BotsAccessSettings{}, nil
}
func (r *Router) onBotsEditAccessSettings(ctx context.Context, req *tg.BotsEditAccessSettingsRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
return false, err
}
return false, botInvalidErr()
}

View file

@ -0,0 +1,316 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"telesrv/internal/domain"
)
func TestBotsLongtailReadStubsReturnEmptyFacts(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
userCtx := WithUserID(ctx, f.owner.ID)
can, err := f.router.onBotsCanSendMessage(userCtx, inputUser(f.bot))
if err != nil || can {
t.Fatalf("canSendMessage = %v,%v, want false,nil", can, err)
}
popular, err := f.router.onBotsGetPopularAppBots(userCtx, &tg.BotsGetPopularAppBotsRequest{Limit: 20})
if err != nil {
t.Fatalf("getPopularAppBots: %v", err)
}
if next, ok := popular.GetNextOffset(); ok || next != "" || len(popular.Users) != 0 {
t.Fatalf("popular app bots = %+v, want empty without next offset", popular)
}
info, err := f.router.onBotsGetPreviewInfo(userCtx, &tg.BotsGetPreviewInfoRequest{
Bot: inputUser(f.bot),
LangCode: "en",
})
if err != nil {
t.Fatalf("getPreviewInfo: %v", err)
}
if len(info.Media) != 0 || len(info.LangCodes) != 0 {
t.Fatalf("preview info = %+v, want empty", info)
}
medias, err := f.router.onBotsGetPreviewMedias(userCtx, inputUser(f.bot))
if err != nil {
t.Fatalf("getPreviewMedias: %v", err)
}
if len(medias) != 0 {
t.Fatalf("preview medias len = %d, want 0", len(medias))
}
access, err := f.router.onBotsGetAccessSettings(userCtx, inputUser(f.bot))
if err != nil {
t.Fatalf("getAccessSettings: %v", err)
}
if access.GetRestricted() {
t.Fatalf("access settings = %+v, want unrestricted empty", access)
}
recs, err := f.router.onBotsGetBotRecommendations(userCtx, inputUser(f.bot))
if err != nil {
t.Fatalf("getBotRecommendations: %v", err)
}
if got := len(recs.(*tg.UsersUsers).Users); got != 0 {
t.Fatalf("recommendations len = %d, want 0", got)
}
recs, err = f.router.onBotsGetBotRecommendations(userCtx, inputUser(f.owner))
if err != nil {
t.Fatalf("getBotRecommendations non-bot target: %v", err)
}
if got := len(recs.(*tg.UsersUsers).Users); got != 0 {
t.Fatalf("recommendations for non-bot target len = %d, want 0", got)
}
}
func TestBotsWriteAccessAllowSendMessageRoundTrip(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
userCtx := WithUserID(ctx, f.owner.ID)
can, err := f.router.onBotsCanSendMessage(userCtx, inputUser(f.bot))
if err != nil || can {
t.Fatalf("can before allow = %v,%v, want false,nil", can, err)
}
updatesClass, err := f.router.onBotsAllowSendMessage(userCtx, inputUser(f.bot))
if err != nil {
t.Fatalf("allowSendMessage: %v", err)
}
updates := updatesClass.(*tg.Updates)
if len(updates.Updates) != 1 {
t.Fatalf("allow updates len = %d, want one service message", len(updates.Updates))
}
newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage)
if !ok {
t.Fatalf("allow update = %T, want UpdateNewMessage", updates.Updates[0])
}
service, ok := newMessage.Message.(*tg.MessageService)
if !ok {
t.Fatalf("allow message = %T, want MessageService", newMessage.Message)
}
action, ok := service.Action.(*tg.MessageActionBotAllowed)
if !ok || !action.FromRequest {
t.Fatalf("allow action = %+v (%T), want messageActionBotAllowed from_request", service.Action, service.Action)
}
if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID {
t.Fatalf("allow service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID)
}
can, err = f.router.onBotsCanSendMessage(userCtx, inputUser(f.bot))
if err != nil || !can {
t.Fatalf("can after allow = %v,%v, want true,nil", can, err)
}
repeat, err := f.router.onBotsAllowSendMessage(userCtx, inputUser(f.bot))
if err != nil {
t.Fatalf("repeat allowSendMessage: %v", err)
}
if got := len(repeat.(*tg.Updates).Updates); got != 0 {
t.Fatalf("repeat allow updates len = %d, want empty", got)
}
history, err := f.router.deps.Messages.GetHistory(ctx, f.owner.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("history: %v", err)
}
serviceMessages := 0
for _, msg := range history.Messages {
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.Kind == domain.MessageServiceActionBotAllowed {
serviceMessages++
}
}
if serviceMessages != 1 {
t.Fatalf("bot allowed service messages = %d, want 1", serviceMessages)
}
}
func TestBotsLongtailRejectsMissingState(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
userCtx := WithUserID(ctx, f.owner.ID)
botCtx := WithUserID(ctx, f.bot.ID)
if _, err := f.router.onBotsInvokeWebViewCustomMethod(userCtx, &tg.BotsInvokeWebViewCustomMethodRequest{
Bot: inputUser(f.bot),
CustomMethod: "unsupported",
Params: tg.DataJSON{Data: "{}"},
}); !tgerr.Is(err, "METHOD_INVALID") {
t.Fatalf("invoke custom method err = %v, want METHOD_INVALID", err)
}
if _, err := f.router.onBotsAddPreviewMedia(userCtx, &tg.BotsAddPreviewMediaRequest{
Bot: inputUser(f.bot),
Media: &tg.InputMediaEmpty{},
}); !tgerr.Is(err, "BOT_APP_INVALID") {
t.Fatalf("add preview media err = %v, want BOT_APP_INVALID", err)
}
if ok, err := f.router.onBotsDeletePreviewMedia(userCtx, &tg.BotsDeletePreviewMediaRequest{
Bot: inputUser(f.bot),
Media: []tg.InputMediaClass{&tg.InputMediaEmpty{}},
}); ok || !tgerr.Is(err, "BOT_APP_INVALID") {
t.Fatalf("delete preview media = %v,%v, want false,BOT_APP_INVALID", ok, err)
}
if _, err := f.router.onBotsGetRequestedWebViewButton(userCtx, &tg.BotsGetRequestedWebViewButtonRequest{
Bot: inputUser(f.bot),
WebappReqID: "req",
}); !tgerr.Is(err, "BUTTON_DATA_INVALID") {
t.Fatalf("get requested button err = %v, want BUTTON_DATA_INVALID", err)
}
if ok, err := f.router.onBotsCheckDownloadFileParams(userCtx, &tg.BotsCheckDownloadFileParamsRequest{
Bot: inputUser(f.bot),
FileName: "file.txt",
URL: "https://127.0.0.1/file.txt",
}); ok || err != nil {
t.Fatalf("check download params = %v,%v, want false,nil", ok, err)
}
if ok, err := f.router.onBotsUpdateUserEmojiStatus(botCtx, &tg.BotsUpdateUserEmojiStatusRequest{
UserID: inputUser(f.owner),
EmojiStatus: &tg.EmojiStatusEmpty{},
}); ok || !tgerr.Is(err, "USER_PERMISSION_DENIED") {
t.Fatalf("update emoji status = %v,%v, want false,USER_PERMISSION_DENIED", ok, err)
}
if ok, err := f.router.onBotsToggleUserEmojiStatusPermission(userCtx, &tg.BotsToggleUserEmojiStatusPermissionRequest{
Bot: inputUser(f.bot),
Enabled: true,
}); !ok || err != nil {
t.Fatalf("toggle emoji permission = %v,%v, want true,nil", ok, err)
}
if ok, err := f.router.onBotsUpdateUserEmojiStatus(botCtx, &tg.BotsUpdateUserEmojiStatusRequest{
UserID: inputUser(f.owner),
EmojiStatus: &tg.EmojiStatusEmpty{},
}); !ok || err != nil {
t.Fatalf("update emoji status after permission = %v,%v, want true,nil", ok, err)
}
if _, err := f.router.onBotsSendCustomRequest(botCtx, &tg.BotsSendCustomRequestRequest{
CustomMethod: "unsupported",
Params: tg.DataJSON{Data: "{}"},
}); !tgerr.Is(err, "METHOD_INVALID") {
t.Fatalf("send custom request err = %v, want METHOD_INVALID", err)
}
if ok, err := f.router.onBotsAnswerWebhookJSONQuery(botCtx, &tg.BotsAnswerWebhookJSONQueryRequest{
QueryID: 1,
Data: tg.DataJSON{Data: "{}"},
}); ok || !tgerr.Is(err, "QUERY_ID_INVALID") {
t.Fatalf("answer webhook json query = %v,%v, want false,QUERY_ID_INVALID", ok, err)
}
}
func TestBotsLongtailCommercialAndSettingsStubs(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
userCtx := WithUserID(ctx, f.owner.ID)
botCtx := WithUserID(ctx, f.bot.ID)
if ok, err := f.router.onBotsSetBotBroadcastDefaultAdminRights(botCtx, tg.ChatAdminRights{}); ok || !tgerr.Is(err, "RIGHTS_NOT_MODIFIED") {
t.Fatalf("broadcast default rights = %v,%v, want false,RIGHTS_NOT_MODIFIED", ok, err)
}
if ok, err := f.router.onBotsSetBotGroupDefaultAdminRights(botCtx, tg.ChatAdminRights{}); ok || !tgerr.Is(err, "RIGHTS_NOT_MODIFIED") {
t.Fatalf("group default rights = %v,%v, want false,RIGHTS_NOT_MODIFIED", ok, err)
}
if ok, err := f.router.onBotsReorderUsernames(userCtx, &tg.BotsReorderUsernamesRequest{
Bot: inputUser(f.bot),
Order: []string{f.bot.Username},
}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("reorder usernames = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
if ok, err := f.router.onBotsToggleUsername(userCtx, &tg.BotsToggleUsernameRequest{
Bot: inputUser(f.bot),
Username: f.bot.Username,
Active: true,
}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") {
t.Fatalf("toggle username = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err)
}
if _, err := f.router.onBotsUpdateStarRefProgram(userCtx, &tg.BotsUpdateStarRefProgramRequest{
Bot: inputUser(f.bot),
CommissionPermille: 100,
}); !tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("update star ref program err = %v, want BOT_INVALID", err)
}
if ok, err := f.router.onBotsSetCustomVerification(userCtx, func() *tg.BotsSetCustomVerificationRequest {
req := &tg.BotsSetCustomVerificationRequest{Peer: inputPeerUser(f.peer)}
req.SetBot(inputUser(f.bot))
req.SetEnabled(true)
return req
}()); ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") {
t.Fatalf("set custom verification = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err)
}
if ok, err := f.router.onBotsEditAccessSettings(userCtx, &tg.BotsEditAccessSettingsRequest{
Bot: inputUser(f.bot),
}); ok || !tgerr.Is(err, "BOT_INVALID") {
t.Fatalf("edit access settings = %v,%v, want false,BOT_INVALID", ok, err)
}
if _, err := f.router.onBotsRequestWebViewButton(botCtx, &tg.BotsRequestWebViewButtonRequest{
UserID: inputUser(f.owner),
Button: &tg.KeyboardButtonSimpleWebView{
Text: "Open",
URL: "https://example.com/app",
},
}); !tgerr.Is(err, "BUTTON_DATA_INVALID") {
t.Fatalf("request webview button err = %v, want BUTTON_DATA_INVALID", err)
}
}
func TestBotsLongtailExplicitStubsAreRegistered(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
userCtx := WithUserID(ctx, f.owner.ID)
botCtx := WithUserID(ctx, f.bot.ID)
userRequests := []struct {
name string
req bin.Encoder
want string
}{
{name: "invokeWebViewCustomMethod", req: &tg.BotsInvokeWebViewCustomMethodRequest{Bot: inputUser(f.bot), CustomMethod: "x", Params: tg.DataJSON{Data: "{}"}}, want: "METHOD_INVALID"},
{name: "addPreviewMedia", req: &tg.BotsAddPreviewMediaRequest{Bot: inputUser(f.bot), Media: &tg.InputMediaEmpty{}}, want: "BOT_APP_INVALID"},
{name: "getRequestedWebViewButton", req: &tg.BotsGetRequestedWebViewButtonRequest{Bot: inputUser(f.bot), WebappReqID: "req"}, want: "BUTTON_DATA_INVALID"},
{name: "reorderUsernames", req: &tg.BotsReorderUsernamesRequest{Bot: inputUser(f.bot), Order: []string{f.bot.Username}}, want: "USERNAME_NOT_MODIFIED"},
{name: "toggleUsername", req: &tg.BotsToggleUsernameRequest{Bot: inputUser(f.bot), Username: f.bot.Username, Active: true}, want: "USERNAME_NOT_MODIFIED"},
{name: "editAccessSettings", req: &tg.BotsEditAccessSettingsRequest{Bot: inputUser(f.bot)}, want: "BOT_INVALID"},
}
for _, tt := range userRequests {
t.Run(tt.name, func(t *testing.T) {
var in bin.Buffer
if err := tt.req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := f.router.Dispatch(userCtx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) {
t.Fatalf("dispatch err = %v, want %s", err, tt.want)
}
})
}
botRequests := []struct {
name string
req bin.Encoder
want string
}{
{name: "sendCustomRequest", req: &tg.BotsSendCustomRequestRequest{CustomMethod: "x", Params: tg.DataJSON{Data: "{}"}}, want: "METHOD_INVALID"},
{name: "answerWebhookJSONQuery", req: &tg.BotsAnswerWebhookJSONQueryRequest{QueryID: 1, Data: tg.DataJSON{Data: "{}"}}, want: "QUERY_ID_INVALID"},
{name: "setBotBroadcastDefaultAdminRights", req: &tg.BotsSetBotBroadcastDefaultAdminRightsRequest{AdminRights: tg.ChatAdminRights{}}, want: "RIGHTS_NOT_MODIFIED"},
{name: "setBotGroupDefaultAdminRights", req: &tg.BotsSetBotGroupDefaultAdminRightsRequest{AdminRights: tg.ChatAdminRights{}}, want: "RIGHTS_NOT_MODIFIED"},
{name: "updateUserEmojiStatus", req: &tg.BotsUpdateUserEmojiStatusRequest{UserID: inputUser(f.owner), EmojiStatus: &tg.EmojiStatusEmpty{}}, want: "USER_PERMISSION_DENIED"},
}
for _, tt := range botRequests {
t.Run(tt.name, func(t *testing.T) {
var in bin.Buffer
if err := tt.req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := f.router.Dispatch(botCtx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) {
t.Fatalf("dispatch err = %v, want %s", err, tt.want)
}
})
}
}

View file

@ -0,0 +1,142 @@
package rpc
import (
"context"
"strconv"
"strings"
"testing"
"go.uber.org/zap/zaptest"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
botsapp "telesrv/internal/app/bots"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newBotRPCTestRouter(t *testing.T) (*Router, *botsapp.Service, *memory.UserStore, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
botStore := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
svc := botsapp.NewService(users, botStore, messages)
owner, err := users.Create(ctx, domain.User{AccessHash: 5101, Phone: "15550005101", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
manager, _, err := svc.CreateBot(ctx, owner.ID, "Manager Bot", "manager_test_bot")
if err != nil {
t.Fatalf("create manager bot: %v", err)
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Bots: svc,
}, zaptest.NewLogger(t), clock.System)
return r, svc, users, owner, manager
}
func TestBotsManagedCreateAndTokenRPCs(t *testing.T) {
ctx := context.Background()
r, svc, _, owner, manager := newBotRPCTestRouter(t)
ownerCtx := WithUserID(ctx, owner.ID)
if ok, err := r.onBotsCheckUsername(ownerCtx, "fresh_rpc_bot"); err != nil || !ok {
t.Fatalf("check free username = %v,%v, want true,nil", ok, err)
}
createdClass, err := r.onBotsCreateBot(ownerCtx, &tg.BotsCreateBotRequest{
Name: "Created Bot",
Username: "fresh_rpc_bot",
ManagerID: &tg.InputUser{UserID: manager.ID, AccessHash: manager.AccessHash},
})
if err != nil {
t.Fatalf("create bot: %v", err)
}
created := createdClass.(*tg.User)
version, hasVersion := created.GetBotInfoVersion()
if !created.Bot || !hasVersion || version < 1 || created.Username != "fresh_rpc_bot" {
t.Fatalf("created user = %+v, want bot with version and username", created)
}
if ok, err := r.onBotsCheckUsername(ownerCtx, "fresh_rpc_bot"); err != nil || ok {
t.Fatalf("check occupied username = %v,%v, want false,nil", ok, err)
}
admined, err := r.onBotsGetAdminedBots(ownerCtx)
if err != nil {
t.Fatalf("get admined bots: %v", err)
}
if len(admined) != 2 {
t.Fatalf("admined bots len = %d, want manager+created", len(admined))
}
token, err := r.onBotsExportBotToken(ownerCtx, &tg.BotsExportBotTokenRequest{
Bot: &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash},
Revoke: false,
})
if err != nil {
t.Fatalf("export token: %v", err)
}
if !strings.HasPrefix(token.Token, "1") || !strings.Contains(token.Token, ":") {
t.Fatalf("token = %q, want <bot_id>:<secret>", token.Token)
}
if !strings.HasPrefix(token.Token, strconv.FormatInt(created.ID, 10)+":") {
t.Fatalf("token = %q, want bot id prefix %d", token.Token, created.ID)
}
rotated, err := r.onBotsExportBotToken(ownerCtx, &tg.BotsExportBotTokenRequest{
Bot: &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash},
Revoke: true,
})
if err != nil {
t.Fatalf("export revoked token: %v", err)
}
if rotated.Token == token.Token {
t.Fatalf("revoke kept token %q", token.Token)
}
profile, found, err := svc.BotInfo(ctx, created.ID)
if err != nil || !found {
t.Fatalf("bot info: found=%v err=%v", found, err)
}
if domain.FormatBotToken(created.ID, profile.TokenSecret) != rotated.Token {
t.Fatalf("stored token = %q, exported %q", domain.FormatBotToken(created.ID, profile.TokenSecret), rotated.Token)
}
}
func TestBotsCreateBotRPCErrorMapping(t *testing.T) {
ctx := context.Background()
r, _, users, owner, _ := newBotRPCTestRouter(t)
ownerCtx := WithUserID(ctx, owner.ID)
if _, err := r.onBotsCreateBot(ownerCtx, &tg.BotsCreateBotRequest{
Name: "Bad Manager",
Username: "bad_manager_bot",
ManagerID: &tg.InputUserSelf{},
}); err == nil || !strings.Contains(err.Error(), "MANAGER_PERMISSION_MISSING") {
t.Fatalf("bad manager err = %v, want MANAGER_PERMISSION_MISSING", err)
}
if _, err := r.onBotsCreateBot(ownerCtx, &tg.BotsCreateBotRequest{
Name: "Bad Username",
Username: "notvalid",
ManagerID: &tg.InputUser{UserID: domain.BotFatherUserID},
}); err == nil || !strings.Contains(err.Error(), "USERNAME_INVALID") {
t.Fatalf("bad username err = %v, want USERNAME_INVALID", err)
}
other, err := users.Create(ctx, domain.User{AccessHash: 5102, Phone: "15550005102", FirstName: "Other"})
if err != nil {
t.Fatalf("create other: %v", err)
}
if _, err := r.onBotsExportBotToken(WithUserID(ctx, other.ID), &tg.BotsExportBotTokenRequest{
Bot: &tg.InputUser{UserID: domain.BotFatherUserID},
Revoke: false,
}); err == nil || !strings.Contains(err.Error(), "BOT_INVALID") {
t.Fatalf("non-owned export err = %v, want BOT_INVALID", err)
}
}

View file

@ -0,0 +1,64 @@
package rpc
import (
"context"
"unicode/utf8"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"telesrv/internal/domain"
)
func startParamInvalidErr() error { return tgerr.New(400, "START_PARAM_INVALID") }
// onMessagesStartBot 处理 messages.startBot深链 telesrv.net/bot?start=payload 与「启动 bot」
// 入口)。语义:向 bot 发送一条可见的 "/start" 或 "/start <param>" 普通私聊消息,走标准
// SendPrivateText 双盒+outbox返回真实 UpdatesI7。bot 经此收到 start_param。
// P3 仅私聊启动peer 为群(加 bot 进群后移P4群内 bot
func (r *Router) onMessagesStartBot(ctx context.Context, req *tg.MessagesStartBotRequest) (tg.UpdatesClass, error) {
if req.RandomID == 0 {
return nil, randomIDEmptyErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, peerIDInvalidErr()
}
if utf8.RuneCountInString(req.StartParam) > domain.MaxStartParamLen {
return nil, startParamInvalidErr()
}
bot, found, err := r.userFromInput(ctx, userID, req.Bot)
if err != nil {
return nil, botInvalidErr()
}
if !found || !bot.Bot {
return nil, botInvalidErr()
}
// P3 仅私聊启动peer 必须解析为该 bot深链 start 的 peer=bot。群启动加 bot
// 进群)属 P4 群内 bot此处拒绝而非静默改写语义。
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeUser || peer.ID != bot.ID {
return nil, peerIDInvalidErr()
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
return nil, err
}
body := "/start"
if req.StartParam != "" {
body += " " + req.StartParam
}
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: body,
})
if err != nil {
return nil, err
}
return updates, nil
}

View file

@ -0,0 +1,127 @@
package rpc
import (
"crypto/rand"
"encoding/binary"
"hash/fnv"
"sync"
"sync/atomic"
"telesrv/internal/domain"
)
// callbackRegistry 是 bot callback query 的进程内挂起表messages.getBotCallbackAnswer
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待bot 经
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道
// getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。
type callbackRegistry struct {
mu sync.Mutex
pending map[int64]*pendingCallback
}
type pendingCallback struct {
ch chan domain.BotCallbackAnswer
done chan struct{} // deregister 时关闭:唤醒超时退出的等待者,消除「答案投递到已离开 select 的 ch」竞态
botUserID int64
userID int64
}
func newCallbackRegistry() *callbackRegistry {
return &callbackRegistry{pending: make(map[int64]*pendingCallback)}
}
// register 登记一次挂起的 callback返回全局唯一 query_id 与接收通道。调用方必须
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
p := &pendingCallback{
ch: make(chan domain.BotCallbackAnswer, 1),
done: make(chan struct{}),
botUserID: botUserID,
userID: userID,
}
c.mu.Lock()
defer c.mu.Unlock()
var queryID int64
for {
queryID = randomNonZeroInt64()
if _, exists := c.pending[queryID]; !exists {
break
}
}
c.pending[queryID] = p
return queryID, p
}
// deregister 移除挂起条目并关闭 done超时/解挂后必调,幂等)。关闭 done 让仍在
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 chTOCTOU
func (c *callbackRegistry) deregister(queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
delete(c.pending, queryID)
close(p.done)
}
c.mu.Unlock()
}
// size 返回当前挂起条目数(测试用:断言超时/解挂后归零、无泄漏)。
func (c *callbackRegistry) size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.pending)
}
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂callerBotID
// 必须等于注册时的 botUserIDI6。返回是否成功投递query 未注册/已超时/非属主 → false
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
c.mu.Lock()
p, ok := c.pending[queryID]
if !ok || p.botUserID != callerBotID {
c.mu.Unlock()
return false
}
delete(c.pending, queryID)
c.mu.Unlock()
// ch 有 1 容量缓冲,非阻塞投递;等待者已超时退出时缓冲被 GC不阻塞。
select {
case p.ch <- ans:
default:
}
return true
}
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
// 无限重试——熵源异常时退化为单调序列兜底query_id 只需进程内唯一register 的
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
func randomNonZeroInt64() int64 {
var buf [8]byte
for i := 0; i < 8; i++ {
if _, err := rand.Read(buf[:]); err != nil {
break
}
if v := int64(binary.LittleEndian.Uint64(buf[:])); v != 0 {
return v
}
}
if v := callbackFallbackSeq.Add(1); v != 0 {
return v
}
return 1
}
// callbackFallbackSeq 是熵源失败时的单调兜底序列(极罕见路径)。
var callbackFallbackSeq atomic.Int64
// chatInstanceFor 为 (bot,user) 私聊派生稳定的 chat_instance同一对话多次 callback
// 间恒定I8。当前用确定性 hash 派生(持久化记 todo
func chatInstanceFor(botUserID, userID int64) int64 {
h := fnv.New64a()
var buf [16]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(userID))
_, _ = h.Write(buf[:])
v := int64(h.Sum64())
if v == 0 {
v = 1
}
return v
}

View file

@ -0,0 +1,77 @@
package rpc
import (
"testing"
"telesrv/internal/domain"
)
func TestCallbackRegistryResolveDelivers(t *testing.T) {
reg := newCallbackRegistry()
queryID, p := reg.register(100, 200)
if queryID == 0 {
t.Fatal("query id must be non-zero")
}
want := domain.BotCallbackAnswer{Alert: true, Message: "hi"}
if !reg.resolve(100, queryID, want) {
t.Fatal("resolve by owner bot must succeed")
}
select {
case got := <-p.ch:
if got != want {
t.Fatalf("delivered %+v, want %+v", got, want)
}
default:
t.Fatal("answer not delivered to channel")
}
// 解挂后条目已删除:再次 resolve 失败、registry 计数归零。
if reg.resolve(100, queryID, want) {
t.Fatal("second resolve must fail (already deregistered)")
}
if n := reg.size(); n != 0 {
t.Fatalf("registry size = %d, want 0 after resolve", n)
}
}
func TestCallbackRegistryNonOwnerRejected(t *testing.T) {
reg := newCallbackRegistry()
queryID, p := reg.register(100, 200)
// 非属主 bot999不得解挂I6
if reg.resolve(999, queryID, domain.BotCallbackAnswer{Message: "evil"}) {
t.Fatal("non-owner resolve must be rejected")
}
select {
case <-p.ch:
t.Fatal("non-owner answer must not be delivered")
default:
}
if n := reg.size(); n != 1 {
t.Fatalf("registry size = %d, want 1 (entry retained after rejected resolve)", n)
}
reg.deregister(queryID)
if n := reg.size(); n != 0 {
t.Fatalf("registry size = %d, want 0 after deregister", n)
}
}
func TestCallbackRegistryUnknownQuery(t *testing.T) {
reg := newCallbackRegistry()
if reg.resolve(100, 12345, domain.BotCallbackAnswer{}) {
t.Fatal("resolve of unregistered query must fail")
}
}
func TestCallbackRegistryUniqueQueryIDs(t *testing.T) {
reg := newCallbackRegistry()
seen := make(map[int64]struct{})
for i := 0; i < 1000; i++ {
q, _ := reg.register(1, 2)
if _, dup := seen[q]; dup {
t.Fatalf("duplicate query id %d", q)
}
seen[q] = struct{}{}
}
if n := reg.size(); n != 1000 {
t.Fatalf("registry size = %d, want 1000", n)
}
}

View file

@ -0,0 +1,46 @@
package rpc
import (
"testing"
"telesrv/internal/domain"
)
// TestChannelFullCanViewParticipants 锁定:广播频道的订阅者列表仅管理员可见(官方语义),
// 非管理员订阅者拿到 can_view_participants=false——否则 DrKLO Profile 会冒出
// Subscribers/Administrators/Channel Settings 三行。超级群成员可见(隐藏成员时仅管理员)。
func TestChannelFullCanViewParticipants(t *testing.T) {
broadcast := domain.Channel{ID: 1, Broadcast: true}
megagroup := domain.Channel{ID: 2, Megagroup: true}
megagroupHidden := domain.Channel{ID: 3, Megagroup: true, ParticipantsHidden: true}
cases := []struct {
name string
ch domain.Channel
role domain.ChannelMemberRole
want bool
}{
{"broadcast-subscriber", broadcast, domain.ChannelRoleMember, false},
{"broadcast-admin", broadcast, domain.ChannelRoleAdmin, true},
{"broadcast-creator", broadcast, domain.ChannelRoleCreator, true},
{"megagroup-member", megagroup, domain.ChannelRoleMember, true},
{"megagroup-admin", megagroup, domain.ChannelRoleAdmin, true},
{"megagroup-hidden-member", megagroupHidden, domain.ChannelRoleMember, false},
{"megagroup-hidden-admin", megagroupHidden, domain.ChannelRoleAdmin, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
view := domain.ChannelView{
Channel: tc.ch,
Self: domain.ChannelMember{
ChannelID: tc.ch.ID,
Role: tc.role,
Status: domain.ChannelMemberActive,
},
}
if got := tgChannelFull(view).CanViewParticipants; got != tc.want {
t.Fatalf("CanViewParticipants = %v, want %v", got, tc.want)
}
})
}
}

View file

@ -0,0 +1,173 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// channelUpdateWirePts 返回一条 channel update 的 channel pts若该 TL update 携带 channel pts
// 只有携带 channel pts 的 update 会推进客户端 channel PtsWaiter是设计 §5 classifier 第一类
// 「channel-payload-pts durable」的判据其余participant=qts、channel state=无 pts不携带。
func channelUpdateWirePts(u tg.UpdateClass) (int, bool) {
switch v := u.(type) {
case *tg.UpdateNewChannelMessage:
return v.Pts, true
case *tg.UpdateEditChannelMessage:
return v.Pts, true
case *tg.UpdateDeleteChannelMessages:
return v.Pts, true
case *tg.UpdatePinnedChannelMessages:
return v.Pts, true
default:
return 0, false
}
}
// TestChannelUpdateEventTypePtsClassification 固化设计 §5 classifier 的基础轴:哪些
// domain.ChannelUpdateEventType 经 tgChannelUpdate 产出「带 channel pts、会推进客户端 channel
// PtsWaiter」的真实 payload→可进 Phase 0 异步 durable fan-out worker哪些不带 channel pts
// participant=qts / noop → 必须排除出 ChannelFanoutJob走专用同步路径
//
// 新增 ChannelUpdateEventType 时必须在此表显式分类:忘记会触发完整性断言失败,强制人为裁决,
// 防止把无 pts / transient 事件误塞进 (channel_id, pts) durable worker设计 §2.1/§8-D6
func TestChannelUpdateEventTypePtsClassification(t *testing.T) {
const viewer = int64(9001)
msg := domain.ChannelMessage{ChannelID: 1, ID: 10, SenderUserID: 2, Date: 1, Body: "hi"}
cases := []struct {
name string
eventType domain.ChannelUpdateEventType
event domain.ChannelUpdateEvent
wantCarriesPts bool // 是否产出带 channel pts 的 wire update= 可进 durable worker
}{
{
name: "new_message",
eventType: domain.ChannelUpdateNewMessage,
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateNewMessage, ChannelID: 1, Pts: 5, PtsCount: 1, Message: msg},
wantCarriesPts: true,
},
{
name: "edit_message",
eventType: domain.ChannelUpdateEditMessage,
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateEditMessage, ChannelID: 1, Pts: 6, PtsCount: 1, Message: msg},
wantCarriesPts: true,
},
{
name: "delete_messages",
eventType: domain.ChannelUpdateDeleteMessages,
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateDeleteMessages, ChannelID: 1, Pts: 7, PtsCount: 1, MessageIDs: []int{10}},
wantCarriesPts: true,
},
{
name: "pinned_messages",
eventType: domain.ChannelUpdatePinnedMessages,
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdatePinnedMessages, ChannelID: 1, Pts: 8, PtsCount: 1, MessageIDs: []int{10}, Pinned: true},
wantCarriesPts: true,
},
{
// participant 是 qts/no-channel-pts必须排除出 durable worker走 participant 专用路径
// (账号级 channel state / getFullChannel / getParticipants 兜底)。设计 §8-D6。
name: "participant",
eventType: domain.ChannelUpdateParticipant,
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateParticipant, ChannelID: 1, Participant: domain.ChannelMember{UserID: 2}},
wantCarriesPts: false,
},
{
name: "noop",
eventType: domain.ChannelUpdateNoop,
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateNoop, ChannelID: 1},
wantCarriesPts: false,
},
}
// 完整性:本表必须覆盖全部已知 ChannelUpdateEventType。新增类型未分类 → 失败。
allKnownTypes := map[domain.ChannelUpdateEventType]struct{}{
domain.ChannelUpdateNewMessage: {},
domain.ChannelUpdateEditMessage: {},
domain.ChannelUpdateDeleteMessages: {},
domain.ChannelUpdateParticipant: {},
domain.ChannelUpdatePinnedMessages: {},
domain.ChannelUpdateNoop: {},
}
covered := make(map[domain.ChannelUpdateEventType]struct{}, len(cases))
for _, tc := range cases {
covered[tc.eventType] = struct{}{}
}
for et := range allKnownTypes {
if _, ok := covered[et]; !ok {
t.Fatalf("ChannelUpdateEventType %q 未在 classifier 固化表分类——新增类型必须显式裁决 pts 归类", et)
}
}
if len(covered) != len(allKnownTypes) {
t.Fatalf("classifier 表覆盖 %d 类型,已知 %d 类型;新增类型须同步 allKnownTypes + 本表", len(covered), len(allKnownTypes))
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
update := tgChannelUpdate(viewer, tc.event)
if tc.eventType == domain.ChannelUpdateNoop {
if update != nil {
t.Fatalf("noop event produced update %#v, want nil", update)
}
return
}
if update == nil {
t.Fatalf("event %q produced nil update", tc.eventType)
}
pts, carries := channelUpdateWirePts(update)
if carries != tc.wantCarriesPts {
t.Fatalf("event %q carriesChannelPts=%v (%T), want %v", tc.eventType, carries, update, tc.wantCarriesPts)
}
if tc.wantCarriesPts && pts != tc.event.Pts {
t.Fatalf("event %q wire pts=%d, want %d", tc.eventType, pts, tc.event.Pts)
}
})
}
}
// TestChannelFanoutEntranceClassificationLocked 把设计 §5 的「channel-scoped 主动投递入口」分类
// 与当前 dispatch 方式固化为测试期边界(防回归 + 文档化为何某些带 channel pts 的入口仍同步)。
//
// 本表是人工维护的清单(不自动反射调用图),与 docs/channel-fanout-async-design.md §5 一致。
// 新增/改动 channel-scoped 主动投递入口时必须同步本表并在 review 中复核分类:
//
// 已异步durable channel-payload-pts → 进 enqueueChannelFanout* 异步 worker
// send / sendMedia / forward / forum-topic-msg / discussion 联动 / edit / geolive-edit / todo /
// bot-inline-edit / delete / deleteParticipantHistory / pin(updatePinnedMessage) / unpinAll /
// TTL-expiry-delete。
// 刻意保持同步且锁定(混合容器 / MustDeliver 失去 difference 恢复面 / 冷起手,详见下表 reason
// createChannel / importChatInvite / inviteToChannel / createChat(invite) / joinChannel /
// leaveChannel / editBanned(kick) / hideChatJoinRequest / hideAllChatJoinRequests /
// editTitle / group-call started/ended/invite 服务消息。
// 无 channel pts排除出 durable workerparticipant / state / TTL-set / forum-pin / read-outbox /
// group-call version / typingviewer-only 无 durable eventpoll / reaction——Phase 4 才迁移。
//
// 此处只断言一组「不变量级」的分类事实,作为编译期/测试期护栏。
func TestChannelFanoutEntranceClassificationLocked(t *testing.T) {
type entrance struct {
name string
carriesChanPts bool
asyncFanout bool // 当前是否走异步 durable worker
reason string // 若带 pts 却仍同步记录原因mixed-container / MustDeliver / 冷起手)
}
// 仅列「带 channel pts」入口无 pts 的 participant/state/typing 由上文事件类型表与设计排除规则覆盖)。
entrances := []entrance{
{name: "send/sendMedia/forward/forum-msg/discussion/edit/geolive/todo/bot-inline/delete/pin/unpinAll/deleteParticipantHistory/expiry-delete", carriesChanPts: true, asyncFanout: true},
{name: "editTitle", carriesChanPts: true, asyncFanout: true, reason: "已拆容器:无 pts UpdateChannel 同步发(无 difference 恢复面),带 pts 标题服务消息(broadcast+megagroup 均产 pts)走异步 fan-out(有 difference 恢复面+nudge 兜底)"},
{name: "createChannel", carriesChanPts: true, asyncFanout: false, reason: "冷起手recipients=创建者+少量初始受邀,无大群放大/无延迟收益RPC result 与首 push 紧耦合"},
{name: "importChatInvite/inviteToChannel/createChat-invite/joinChannel/hideChatJoinRequest/hideAllChatJoinRequests", carriesChanPts: true, asyncFanout: false, reason: "经 channelOperationUpdates 混合容器pts 服务消息 + 无 pts UpdateChannelbroadcast 仅 UpdateChannel且新成员 MustDeliver 失去 difference 恢复面——须先拆容器+no-fold 才能异步"},
{name: "leaveChannel/editBanned(kick)", carriesChanPts: true, asyncFanout: false, reason: "离开/被踢者在 recipients 内且已失去 channel difference 恢复面MustDeliver可丢弃队列无 no-fold 通道participant 部分无 pts"},
{name: "groupCallServiceMessage", carriesChanPts: true, asyncFanout: false, reason: "经 channelOperationUpdates 混合容器version 信令须留特殊路径,只能拆出服务消息再异步"},
}
for _, e := range entrances {
if !e.carriesChanPts {
t.Fatalf("entrance %q 误入本表(本表仅列带 channel pts 入口)", e.name)
}
if !e.asyncFanout && e.reason == "" {
t.Fatalf("entrance %q 带 channel pts 却仍同步必须记录保持同步的原因mixed-container/MustDeliver/冷起手)", e.name)
}
}
}

View file

@ -0,0 +1,409 @@
package rpc
import (
"context"
"sync"
"sync/atomic"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// channel fan-out 异步化(设计 docs/channel-fanout-async-design.md Phase 0 / §9 v1 最小实现)。
//
// 目标:把频道 payload fan-out 移出发送者 RPC 同步路径——发送者只等事务 + 自己视角 echo
// rpc_result其余在线成员的投递交给本 dispatcher 的后台 worker。
//
// v1单实例关键取舍
// - durable 真值仍是 channel_update_events本 dispatcher 只做 best-effort 在线加速,
// 丢失由客户端 getChannelDifference 兜底(设计决策 1
// - 按 channelID 哈希到固定分片,使同一 channel 串行处理FIFO → 单调 pts满足
// DrKLO Android ~1.5s 乱序窗口与 TDesktop PtsWaiter 的连续性期望(设计 §10.2)。
// - 单实例 + 无 durable 重投队列 + 同 channel 串行 → 无乱序、无自重复,故 v1 不需要
// per-session at-most-once 双水位(那是 Phase 3 跨实例的事,设计 §9/§10.1)。
// - 有界队列满时丢弃当前 job 并告警:被丢 recipient 会在该 channel 下一条成功投递的
// pts 跳变时经 getChannelDifference 收敛(设计约束 B
const (
defaultChannelFanoutShards = 64
defaultChannelFanoutBuffer = 2048
)
// channelFanoutBuilder 按 viewer 构建该 viewer 视角的 channel updates。与同步
// channelUpdatesBuilder 不同,它接受 worker 提供的后台 ctx请求 ctx 在 fan-out 异步
// 执行时已被取消),且实现不得从 ctx 读取 viewer/auth 派生数据viewerUserID 显式传入)。
type channelFanoutBuilder func(ctx context.Context, viewerUserID int64) *tg.Updates
// channelFanoutJob 是一条频道 payload fan-out 任务。Pts 仅用于日志/折叠语义;真值仍是
// channel_update_eventsworker 只做在线投递。originAuthKeyID 是业务视角 auth key
// (与 SessionManager.shouldExcludeSession 的比较侧一致),用于显式排除发起设备——异步
// 执行时请求 ctx 已失效,不能再靠 ctx 派生排除。
type channelFanoutJob struct {
scope channelFanoutScope
originUserID int64
channelID int64
pts int
recipients []int64
originAuthKeyID [8]byte
originSessionID int64
prefetch channelFanoutPrefetch
build channelFanoutBuilder
}
// channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次,
// 用于跨全部 recipient 一次性预热每 viewer 的用户投影fan-out 模板化O(owner))。可选:为 nil
// 时 build 仍逐 viewer 解析(行为不变)。在 worker goroutine 内串行执行,与 build 共享同一
// viewerPeerCache无跨 goroutine 竞态。
type channelFanoutPrefetch func(ctx context.Context, viewers []int64)
// channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC按 channelID 分片串行处理。
type channelFanoutDispatcher struct {
r *Router
log *zap.Logger
shards []chan channelFanoutJob
started atomic.Bool
dropped atomic.Int64
}
// enqueueChannelFanout 把一条 channel-payload-pts 的 fan-out 投入异步 dispatcher。
// 从请求 ctx 抓取发起设备的业务 auth key + session_id 显式带入 job使异步 worker 仍能
// 排除发起设备回显(请求 ctx 异步时已失效)。仅用于会推进客户端 channel PtsWaiter 的真实
// payload新消息/编辑/删除/pinreaction/pollviewer-only 零 pts、participant/TTL/
// channel state无 channel pts、typingtransient不走此路径设计 §2.1/§5 分类)。
func (r *Router) enqueueChannelFanout(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, build channelFanoutBuilder) {
r.enqueueChannelFanoutWithPrefetch(ctx, scope, originUserID, channelID, pts, recipients, nil, build)
}
// enqueueChannelFanoutWithPrefetch 同 enqueueChannelFanout但额外带一个跨 viewer 用户投影预热钩子
// fan-out 模板化把每 recipient 的逐 viewer 投影折叠成一次 O(owner) 投影;见 prefetchChannelFanoutUsers
func (r *Router) enqueueChannelFanoutWithPrefetch(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, prefetch channelFanoutPrefetch, build channelFanoutBuilder) {
if r.channelFanout == nil || build == nil {
return
}
originAuthKeyID, _ := AuthKeyIDFrom(ctx)
originSessionID, _ := SessionIDFrom(ctx)
r.channelFanout.Enqueue(ctx, channelFanoutJob{
scope: scope,
originUserID: originUserID,
channelID: channelID,
pts: pts,
recipients: recipients,
originAuthKeyID: originAuthKeyID,
originSessionID: originSessionID,
prefetch: prefetch,
build: build,
})
}
// RunChannelFanout 启动频道 fan-out 后台 worker由 main 与其它 dispatcher 一同 go 起。
// 阻塞到 ctx 取消;未调用前 fan-out 同步执行(行为同旧版)。
func (r *Router) RunChannelFanout(ctx context.Context) {
r.channelFanout.Run(ctx)
}
func newChannelFanoutDispatcher(r *Router, shards, buffer int) *channelFanoutDispatcher {
if shards <= 0 {
shards = defaultChannelFanoutShards
}
if buffer <= 0 {
buffer = defaultChannelFanoutBuffer
}
d := &channelFanoutDispatcher{r: r, log: r.log.Named("channel-fanout"), shards: make([]chan channelFanoutJob, shards)}
for i := range d.shards {
d.shards[i] = make(chan channelFanoutJob, buffer)
}
return d
}
// Run 启动 worker每分片一个 goroutine保证同 channel 串行。阻塞到 ctx 取消。
// 未调用 Run 前 Enqueue 回退为同步执行(保持测试/未装配场景行为不变)。
func (d *channelFanoutDispatcher) Run(ctx context.Context) {
if d == nil || !d.started.CompareAndSwap(false, true) {
return
}
var wg sync.WaitGroup
for i := range d.shards {
wg.Add(1)
ch := d.shards[i]
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job := <-ch:
d.r.runChannelFanoutJob(ctx, job)
}
}
}()
}
wg.Wait()
}
func (d *channelFanoutDispatcher) shardIndex(channelID int64) int {
n := int64(len(d.shards))
idx := channelID % n
if idx < 0 {
idx += n
}
return int(idx)
}
// Enqueue 投递一条 fan-out 任务。dispatcher 未启动时同步执行(用请求 ctx保持旧行为
// 已启动时投入对应分片,满则丢弃 + 告警(该 channel 下一条消息的 pts 跳变会经
// getChannelDifference 兜底)。
func (d *channelFanoutDispatcher) Enqueue(reqCtx context.Context, job channelFanoutJob) {
if d == nil || job.build == nil {
return
}
if !d.started.Load() {
d.r.runChannelFanoutJob(reqCtx, job)
return
}
shard := d.shards[d.shardIndex(job.channelID)]
select {
case shard <- job:
default:
d.dropped.Add(1)
d.log.Warn("channel fanout queue full, dropped realtime push (recovered via next pts gap / getChannelDifference)",
zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts))
}
}
// runChannelFanoutJob 执行一条 fan-out与同步 pushChannelUpdatesWithScope 等价,区别是
// build 接受 ctx、且排除发起设备用 job 显式携带的 (originAuthKeyID, originSessionID)
// 叠加到 ctx 后复用 pushUserUpdates而非依赖已失效的请求 ctx。
func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob) {
if r.deps.Sessions == nil || job.build == nil {
return
}
pushCtx := WithSessionID(WithAuthKeyID(ctx, job.originAuthKeyID), job.originSessionID)
recipients := r.channelFanoutRecipients(ctx, job.scope, job.channelID, job.recipients)
// 预热跨 viewer 用户投影fan-out 模板化):在逐 viewer build 之前一次性算好每 recipient 的
// 投影并预热共享 cache使 build 只命中缓存、不再 O(viewer) 逐个 ForViewer。覆盖 recipients +
// 兜底 origin无在线 recipient 时 build 会回退给 origin。失败/未实现时静默退化为逐 viewer。
if job.prefetch != nil {
viewers := recipients
if job.originUserID != 0 {
viewers = append(append(make([]int64, 0, len(recipients)+1), recipients...), job.originUserID)
}
job.prefetch(ctx, viewers)
}
seen := make(map[int64]struct{}, len(recipients))
pushed := false
for _, userID := range recipients {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
updates := job.build(ctx, userID)
if updates == nil {
continue
}
r.pushUserUpdates(pushCtx, userID, updates)
pushed = true
}
if !pushed && job.originUserID != 0 {
seen[job.originUserID] = struct{}{}
if updates := job.build(ctx, job.originUserID); updates != nil {
r.pushUserUpdates(pushCtx, job.originUserID, updates)
}
}
// P0-8完整 payload 受 MaxChannelRealtimeFanout 封顶,超出 cap 的在线成员既收不到
// payload 也收不到任何东西(频道纯拉模型不会自发轮询)。给这些「已在线但未收完整 payload」
// 的成员发廉价 UpdateChannelTooLong{pts} nudge促其 getChannelDifference 收敛。
// 仅对会推进客户端 channel PtsWaiter 的真实 payloadmembers scope + 带 channel pts做。
if job.scope == channelFanoutMembers && job.pts > 0 {
r.nudgeBeyondCapChannelMembers(pushCtx, job.channelID, job.pts, seen)
}
}
// prefetchChannelFanoutUsers 跨全部 recipient 一次性投影 owner 用户fan-out 模板化O(owner)
// 把结果按 viewer 预热进共享 cache之后每 viewer 的 build 只命中缓存,不再逐 viewer ForViewer。
// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 未实现 BatchViewerUsersResolver 或解析
// 失败时静默跳过——build 回退逐 viewer 解析,行为不变,仅退化为旧的 O(viewer) 成本。
func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) {
if cache == nil || len(viewers) == 0 || len(ownerIDs) == 0 || r.deps.Users == nil {
return
}
resolver, ok := r.deps.Users.(BatchViewerUsersResolver)
if !ok {
return
}
byViewer, err := resolver.ByIDsForViewers(ctx, viewers, ownerIDs)
if err != nil {
r.log.Warn("channel fanout user prefetch failed; falling back to per-viewer projection",
zap.Int("viewers", len(viewers)), zap.Int("owners", len(ownerIDs)), zap.Error(err))
return
}
for viewer, users := range byViewer {
cache.primeUsers(viewer, users)
}
}
// channelMessageFanoutOwnerIDs 收集一条频道消息 fan-out 会下发到 Users 数组里的全部 owner 用户 id
// sender/from/send_as/forward/via_bot/reply/contact/poll 等 peer refs
// channelMessagesUpdatesWithPeerCache 的收集口径一致),用于预热跨 viewer 投影。
func channelMessageFanoutOwnerIDs(res domain.SendChannelMessageResult, extraUserIDs []int64) []int64 {
return channelMessagesFanoutOwnerIDs([]domain.SendChannelMessageResult{res}, extraUserIDs)
}
// channelMessagesFanoutOwnerIDs 同上,但取多条结果(批量转发汇成一个 job的 owner id 并集。
func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, extraUserIDs []int64) []int64 {
userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs)+4)
channelIDs := make(map[int64]struct{})
for _, id := range extraUserIDs {
if id != 0 {
userIDs[id] = struct{}{}
}
}
for _, res := range results {
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
}
return peerIDMapKeys(userIDs)
}
// enqueueChannelMessageFanout 异步 fan-out 单条频道消息并预热跨 viewer 投影(「频道里出现一条新消息」
// 类事件的常见形态:发送/转发单条/讨论组联动/forum topic 消息)。语义与 enqueueChannelFanout 一致,
// 仅多了把每 viewer 投影一次性算好预热进共享 cacheO(owner)),不改变投递/排除/nudge 行为。
func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult, extraUserIDs []int64) {
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(res, extraUserIDs)
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
// privacy bot 在 send 时被 SkipDeliveryUserIDs 排除(命令/@/回复以外的消息不可见)。
// channelFanoutRecipients 按「在线活跃成员」重算 recipients 会把它加回,故在线 fanout
// 必须在此跳过它的直接推送,否则在线 bot 仍能实时收到群里全部消息(持久 history/
// difference 已正确隐藏仅此直接推送泄漏内容。nudge 安全bot 落在 seen 里不被 nudge
// 即便 nudge 也会被 filterBotChannelDifference 过滤掉隐藏消息。
if _, skipped := skip[viewerUserID]; skipped {
return nil
}
return r.channelMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, 0, fanoutCache)
})
}
// skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合nil 表示无排除)。
func skipDeliverySet(ids []int64) map[int64]struct{} {
if len(ids) == 0 {
return nil
}
set := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id != 0 {
set[id] = struct{}{}
}
}
return set
}
// channelEditMessageFanoutOwnerIDs 收集一条频道编辑 fan-out 会下发到 Users 数组里的全部 owner 用户 id。
// 严格镜像 channelEditMessageUpdates 的两容器 + pts 门控收集口径Event/Message 仅 Event.Pts!=0 时收,
// ServiceEvent/ServiceMessage 仅 ServiceEvent.Pts!=0 时收,对应 todo 编辑的服务消息第二容器),使预热
// owner 集与 build 实际下发的 Users 集恰好一致——多收只会无害多预热,但镜像门控让等价测试最紧。
func channelEditMessageFanoutOwnerIDs(res domain.EditChannelMessageResult) []int64 {
userIDs := make(map[int64]struct{}, 4)
channelIDs := make(map[int64]struct{})
if res.Event.Pts != 0 {
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
}
if res.ServiceEvent.Pts != 0 {
collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs)
}
return peerIDMapKeys(userIDs)
}
// enqueueChannelEditMessageFanout 异步 fan-out 一条频道编辑并预热跨 viewer 投影editMessage/geolive/
// todo/bot-inline edit 的共同形态)。语义与原 enqueueChannelFanout(channelEditMessageUpdates) 一致,
// 仅多了把每 viewer 投影一次性算好预热进共享 cacheO(owner))。注意 edit 不做 per-viewer mention
// overlayEditChannelMessageResult 不带 MentionUserIDs编辑新增 @ 走 durable channel_unread_mentions
// 经 getChannelDifference 自愈),故各 viewer 的 mentioned/media_unread 字节恒等,预热不影响等价。
//
// nudge pts 取两容器较大值edit 可产两条带 pts 事件Event=编辑本身、ServiceEvent=如 todo 完成的
// "X completed Y" 服务消息ServiceEvent.Pts 在后分配恒更大;某些编辑只产 ServiceEventEvent.Pts==0
// nudge 须带 channel 当前最高 pts 才能让 >cap 在线成员的 getChannelDifference 拉齐到末尾——用 Event.Pts
// 会在 Event.Pts==0 时漏发 nudge、或低于真实 pts。max() 在三种形态(仅 Event/仅 ServiceEvent/两者)都正确。
func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUserID int64, res domain.EditChannelMessageResult) {
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelEditMessageFanoutOwnerIDs(res)
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, nudgePts, res.Recipients,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelEditMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache)
})
}
// enqueueChannelMessagesFanout 同 enqueueChannelMessageFanout但把多条结果汇成一个 job批量转发
// 一个 Updates 内含多条 UpdateNewChannelMessagepeer refs 取全部结果并集预热。channelID/pts/
// recipients 由调用方按批量语义给定pts 取最后一条recipients 受大群截断口径影响)。
func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID, channelID int64, pts int, recipients []int64, results []domain.SendChannelMessageResult, extraUserIDs []int64) {
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessagesFanoutOwnerIDs(results, extraUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, channelID, pts, recipients,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelMessagesUpdatesWithPeerCache(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache)
})
}
// defaultChannelNudgeMaxTargets 限一次 fan-out 的 nudge 上限nudge 是 O(1)/人廉价 push但 nudge 被
// 消费后客户端会 getChannelDifferenceDrKLO 对未加载频道还会先 getPeerDialogs设计 §10.3
// 大群高频下可能放大。可经 Config.ChannelNudgeMaxTargets 覆盖;客户端侧由 difference/getPeerDialogs
// 的 FLOOD_WAITPhase 2兜底见 checkCatchupRateLimit
const defaultChannelNudgeMaxTargets = 50000
// channelNudgeMaxTargets 返回生效的 nudge 上限Config 覆盖,否则默认)。
func (r *Router) channelNudgeMaxTargets() int {
if r.cfg.ChannelNudgeMaxTargets > 0 {
return r.cfg.ChannelNudgeMaxTargets
}
return defaultChannelNudgeMaxTargets
}
// nudgeBeyondCapChannelMembers 给频道在线成员中未收到完整 payload不在 delivered 内)的成员发
// UpdateChannelTooLong{pts}。nudge 必须带 ptsflags&1——DrKLO 对不带 pts 的 tooLong 不触发
// getChannelDifference设计 §10.3)。走 pushUserUpdatesbest-effort、未就绪入 pending、非
// transient符合设计 §决策4 的 nudge 投递可靠性要求。SessionManager 未实现 ChannelNudgeProvider
// 时(测试/未装配)静默跳过,不影响完整 payload 投递。
func (r *Router) nudgeBeyondCapChannelMembers(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) {
provider, ok := r.deps.Sessions.(ChannelNudgeProvider)
if !ok || channelID == 0 || pts <= 0 {
return
}
targets := provider.OnlineChannelMemberUserIDsExcluding(channelID, delivered, r.channelNudgeMaxTargets())
if len(targets) == 0 {
return
}
date := int(r.clock.Now().Unix())
for _, userID := range targets {
if userID == 0 {
continue
}
tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID}
tooLong.SetPts(pts)
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{tooLong},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
})
}
}

View file

@ -0,0 +1,319 @@
package rpc
import (
"context"
"sync"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func fanoutTestJob(recipients []int64, originUser, originSession int64, built map[int64]bool) channelFanoutJob {
return channelFanoutJob{
scope: channelFanoutMembers,
originUserID: originUser,
channelID: 1001,
pts: 5,
recipients: recipients,
originSessionID: originSession,
build: func(_ context.Context, viewerUserID int64) *tg.Updates {
if built != nil {
built[viewerUserID] = true
}
return &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateChannelTooLong{ChannelID: 1001}}, Date: 1}
},
}
}
func fanoutHasID(ids []int64, want int64) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// TestChannelFanoutDispatcherSyncFallbackdispatcher 未启动时 Enqueue 同步执行——
// 保持测试/未装配场景行为不变recipients 立即被推送、发起 session 作为 exclude 透传。
// deps.Channels=nil 时 channelFanoutRecipients 直接返回 explicit recipients。
func TestChannelFanoutDispatcherSyncFallback(t *testing.T) {
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
built := map[int64]bool{}
r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{2001, 2002}, 0, 99, built))
pushed := cs.pushedUserIDs()
if len(pushed) != 2 || !fanoutHasID(pushed, 2001) || !fanoutHasID(pushed, 2002) {
t.Fatalf("sync fallback pushed = %v, want [2001 2002]", pushed)
}
if got := cs.snapshot().sessionID; got != 99 {
t.Fatalf("exclude session = %d, want 99 (origin session passed explicitly, not via request ctx)", got)
}
if !built[2001] || !built[2002] {
t.Fatalf("build not invoked per viewer: %v", built)
}
}
// TestChannelFanoutDispatcherDeliversAsyncdispatcher 启动后 Enqueue 异步投递,
// recipients 最终被 worker 推送(不阻塞 Enqueue 调用方)。
func TestChannelFanoutDispatcherDeliversAsync(t *testing.T) {
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go r.RunChannelFanout(ctx)
for i := 0; i < 200 && !r.channelFanout.started.Load(); i++ {
time.Sleep(time.Millisecond)
}
if !r.channelFanout.started.Load() {
t.Fatal("dispatcher did not start")
}
// built map 跨 goroutine只断言 mutex 保护的 pushedUserIDs不读 built。
r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{3001}, 0, 7, nil))
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if fanoutHasID(cs.pushedUserIDs(), 3001) {
break
}
time.Sleep(2 * time.Millisecond)
}
if !fanoutHasID(cs.pushedUserIDs(), 3001) {
t.Fatalf("async fan-out did not deliver to 3001: %v", cs.pushedUserIDs())
}
if got := cs.snapshot().sessionID; got != 7 {
t.Fatalf("exclude session = %d, want 7 (origin carried into job, not lost on bg ctx)", got)
}
}
// TestChannelFanoutDispatcherInvokesPrefetchworker 在逐 viewer build 之前调用一次 prefetch
// 且传入「解析后的 recipients + 兜底 origin」——这是 fan-out 跨 viewer 投影预热O(owner))的入口。
func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) {
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
var gotViewers []int64
job := fanoutTestJob([]int64{2001, 2002}, 5, 99, nil)
job.prefetch = func(_ context.Context, viewers []int64) {
gotViewers = append([]int64(nil), viewers...)
}
// deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001 2002]origin=5 兜底追加。
r.channelFanout.Enqueue(context.Background(), job)
want := map[int64]bool{2001: true, 2002: true, 5: true}
if len(gotViewers) != len(want) {
t.Fatalf("prefetch viewers = %v, want recipients+origin %v", gotViewers, want)
}
for _, v := range gotViewers {
if !want[v] {
t.Fatalf("prefetch viewers = %v, unexpected %d (want recipients+origin)", gotViewers, v)
}
}
}
// editFanoutTestResult 构造一条覆盖两容器的 EditChannelMessageResult主容器(Event/Message)带
// sender A + reply B服务消息容器(ServiceEvent/ServiceMessage)带 sender C + Action.UserIDs=[D]。
func editFanoutTestResult(eventPts, servicePts int) domain.EditChannelMessageResult {
res := domain.EditChannelMessageResult{
Channel: domain.Channel{ID: 1001},
Recipients: []int64{3001, 3002},
}
res.Event = domain.ChannelUpdateEvent{Pts: eventPts, SenderUserID: 2001, Message: domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2001}}
res.Message = domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2001, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}}}
res.ServiceEvent = domain.ChannelUpdateEvent{Pts: servicePts, SenderUserID: 2003, Message: domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2003}}
res.ServiceMessage = domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2003, Action: &domain.ChannelMessageAction{Type: domain.ChannelActionTodoCompletions, UserIDs: []int64{2004}}}
return res
}
func ownerIDSet(ids []int64) map[int64]bool {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = true
}
return out
}
// TestChannelEditMessageFanoutOwnerIDsCoversBothContainers编辑预热的 owner-id 收集必须并集两个
// 容器(主消息 + 服务消息),否则服务消息的 sender/Action.UserIDs 漏出缓存预热edit 相对 send
// 路径唯一新增的等价面,见 editVerify
func TestChannelEditMessageFanoutOwnerIDsCoversBothContainers(t *testing.T) {
got := ownerIDSet(channelEditMessageFanoutOwnerIDs(editFanoutTestResult(5, 6)))
for _, want := range []int64{2001, 2002, 2003, 2004} {
if !got[want] {
t.Fatalf("owner ids %v missing %d (both containers must be unioned)", got, want)
}
}
}
// TestChannelEditMessageFanoutOwnerIDsGatingowner-id 收集必须严格镜像 builder 的 pts 门控——
// ServiceEvent.Pts==0 时不收服务消息容器Event.Pts==0 时不收主容器。保证预热集与 build 下发的
// Users 集恰好一致(多收无害但破坏等价测试紧致性)。
func TestChannelEditMessageFanoutOwnerIDsGating(t *testing.T) {
// 仅主容器(服务消息 pts=0
noService := ownerIDSet(channelEditMessageFanoutOwnerIDs(editFanoutTestResult(5, 0)))
if !noService[2001] || !noService[2002] {
t.Fatalf("event-only owner ids %v should contain 2001/2002", noService)
}
if noService[2003] || noService[2004] {
t.Fatalf("event-only owner ids %v must not contain service-container ids 2003/2004", noService)
}
// 两容器都无 pts → 空。
if ids := channelEditMessageFanoutOwnerIDs(editFanoutTestResult(0, 0)); len(ids) != 0 {
t.Fatalf("no-pts owner ids = %v, want empty", ids)
}
}
// prefetchRecordingUsersService 在 mapUsersService 基础上实现 BatchViewerUsersResolver 并记录
// ByIDsForViewers 收到的 (viewers, ownerIDs),用于断言 edit fan-out 用正确 owner 集预热。
type prefetchRecordingUsersService struct {
mapUsersService
mu sync.Mutex
gotViewers []int64
gotOwnerIDs []int64
forViewerCall int
}
func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewerUserIDs, userIDs []int64) (map[int64][]domain.User, error) {
s.mu.Lock()
s.forViewerCall++
s.gotViewers = append([]int64(nil), viewerUserIDs...)
s.gotOwnerIDs = append([]int64(nil), userIDs...)
s.mu.Unlock()
out := make(map[int64][]domain.User, len(viewerUserIDs))
for _, v := range viewerUserIDs {
out[v] = nil
}
return out, nil
}
// TestChannelEditMessageFanoutInvokesPrefetchenqueueChannelEditMessageFanout 在逐 viewer build
// 前用「channelEditMessageFanoutOwnerIDs(res) + recipients+origin」预热dispatcher 未启动→同步
// 回退prefetch 同步执行)。锁定 edit 路径接入了 O(owner) 预热而非逐 viewer 投影。
func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) {
users := &prefetchRecordingUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{}}}
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs, Users: users}, zaptest.NewLogger(t), clock.System)
res := editFanoutTestResult(5, 6)
r.enqueueChannelEditMessageFanout(context.Background(), 5, res)
if users.forViewerCall != 1 {
t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", users.forViewerCall)
}
gotViewers := ownerIDSet(users.gotViewers)
for _, want := range []int64{3001, 3002, 5} {
if !gotViewers[want] {
t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", users.gotViewers, want)
}
}
gotOwners := ownerIDSet(users.gotOwnerIDs)
for _, want := range []int64{2001, 2002, 2003, 2004} {
if !gotOwners[want] {
t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", users.gotOwnerIDs, want)
}
}
}
// nudgeSessions 在 captureSessions 基础上实现 ChannelNudgeProvider 并按 user 记录最近一次推送,
// 用于断言 >cap 在线成员收到带 pts 的 UpdateChannelTooLong nudge。
type nudgeSessions struct {
*captureSessions
online []int64
mu sync.Mutex
byUser map[int64]bin.Encoder
}
func newNudgeSessions(online []int64) *nudgeSessions {
return &nudgeSessions{captureSessions: &captureSessions{}, online: online, byUser: map[int64]bin.Encoder{}}
}
func (s *nudgeSessions) PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
s.mu.Lock()
s.byUser[userID] = msg
s.mu.Unlock()
return s.captureSessions.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg)
}
func (s *nudgeSessions) OnlineChannelMemberUserIDsExcluding(_ int64, exclude map[int64]struct{}, limit int) []int64 {
out := make([]int64, 0, len(s.online))
for _, id := range s.online {
if _, ok := exclude[id]; ok {
continue
}
out = append(out, id)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func (s *nudgeSessions) msgFor(userID int64) bin.Encoder {
s.mu.Lock()
defer s.mu.Unlock()
return s.byUser[userID]
}
// TestChannelFanoutDispatcherNudgesBeyondCapMembersP0-8完整 payload 投递给 cap 内
// recipients 后cap 外在线成员收到带 pts 的 UpdateChannelTooLong nudgecap 内成员不重复 nudge。
func TestChannelFanoutDispatcherNudgesBeyondCapMembers(t *testing.T) {
cs := newNudgeSessions([]int64{2001, 2002, 2003})
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
// deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001](收完整 payload
// 2002/2003 是 cap 外在线成员OnlineChannelMemberUserIDsExcluding 排除 2001 后返回)。
r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{2001}, 0, 99, nil))
pushed := cs.pushedUserIDs()
for _, want := range []int64{2001, 2002, 2003} {
if !fanoutHasID(pushed, want) {
t.Fatalf("user %d not pushed: %v", want, pushed)
}
}
// 2002/2003 必须是带 pts 的 UpdateChannelTooLongDrKLO 对不带 pts 的 tooLong 不触发 difference
for _, uid := range []int64{2002, 2003} {
ups, ok := cs.msgFor(uid).(*tg.Updates)
if !ok || len(ups.Updates) != 1 {
t.Fatalf("nudge to %d not single-update *tg.Updates: %#v", uid, cs.msgFor(uid))
}
tl, ok := ups.Updates[0].(*tg.UpdateChannelTooLong)
if !ok {
t.Fatalf("nudge to %d not UpdateChannelTooLong: %#v", uid, ups.Updates[0])
}
if p, ok := tl.GetPts(); !ok || p != 5 {
t.Fatalf("nudge to %d pts=%d ok=%v, want 5 (must carry pts)", uid, p, ok)
}
}
}
// TestChannelEditMessageFanoutNudgePtsUsesMaxContaineredit 可只产服务消息容器Event.Pts==0、
// ServiceEvent.Pts!=0如纯 todo 完成)。此时 >cap 在线成员的 nudge 必须带 ServiceEvent.Pts两容器
// 较大值),否则用 Event.Pts==0 会被 job.pts>0 门控吞掉 nudge、beyond-cap 成员错过 getChannelDifference。
func TestChannelEditMessageFanoutNudgePtsUsesMaxContainer(t *testing.T) {
cs := newNudgeSessions([]int64{3001, 4001}) // 4001 是 cap 外在线成员(不在 recipients
r := New(Config{}, Deps{Sessions: cs, Users: mapUsersService{users: map[int64]domain.User{}}}, zaptest.NewLogger(t), clock.System)
// 仅服务消息容器有 ptsEvent.Pts=0, ServiceEvent.Pts=11。deps.Channels=nil → recipients=[3001 3002]。
res := editFanoutTestResult(0, 11)
r.enqueueChannelEditMessageFanout(context.Background(), 0, res)
ups, ok := cs.msgFor(4001).(*tg.Updates)
if !ok || len(ups.Updates) != 1 {
t.Fatalf("nudge to 4001 not single-update *tg.Updates: %#v", cs.msgFor(4001))
}
tl, ok := ups.Updates[0].(*tg.UpdateChannelTooLong)
if !ok {
t.Fatalf("nudge to 4001 not UpdateChannelTooLong: %#v", ups.Updates[0])
}
if p, ok := tl.GetPts(); !ok || p != 11 {
t.Fatalf("nudge pts=%d ok=%v, want 11 (max(Event=0, Service=11))", p, ok)
}
}

View file

@ -0,0 +1,81 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// privacyBotResolver 让指定 user 成为 bot_chat_history=false 的隐私 bot命令/@/回复以外的群消息不可见)。
type privacyBotResolver map[int64]bool
func (p privacyBotResolver) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if _, ok := p[botUserID]; !ok {
return domain.BotProfile{}, false, nil
}
return domain.BotProfile{BotUserID: botUserID, ChatHistory: false}, true, nil
}
// TestChannelMessageFanoutSkipsPrivacyBotOnlinePush 回归:在线 privacy bot 不得经实时 fanout 收到群里
// 的纯文本消息。修复前 channelFanoutRecipients 按「在线活跃成员」重算 recipients,会把 send 时已按
// SkipDeliveryUserIDs 排除的 bot 加回 → 在线 bot 实时收到全部群聊内容(持久 history/difference 已正确
// 隐藏,仅此直接推送泄漏内容)。修复后 fanout build 对被 skip 的 viewer 返回 nil,不推送。
func TestChannelMessageFanoutSkipsPrivacyBotOnlinePush(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
bots := privacyBotResolver{1003: true}
channelService := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots))
created, err := channelService.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
Title: "Privacy",
MemberUserIDs: []int64{1002, 1003},
Date: 10,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
// 纯文本消息:privacy bot 1003 不可见 → SendMessage 把它放进 SkipDeliveryUserIDs、排除出 Recipients。
res, err := channelService.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 2001,
Message: "plain group chatter",
Date: 11,
})
if err != nil {
t.Fatalf("send: %v", err)
}
if !fanoutHasID(res.SkipDeliveryUserIDs, 1003) {
t.Fatalf("res.SkipDeliveryUserIDs = %v, want privacy bot 1003 excluded at send", res.SkipDeliveryUserIDs)
}
if fanoutHasID(res.Recipients, 1003) {
t.Fatalf("res.Recipients = %v, privacy bot 1003 must not be a delivery recipient", res.Recipients)
}
// 两成员都在线;channelFanoutRecipients 据此把 1003 当在线活跃成员加回 recipients。
sessions := &captureSessions{
channelMembers: map[int64][]int64{created.Channel.ID: {1002, 1003}},
}
r := New(Config{}, Deps{Channels: channelService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
// 复核前置:修复前后 channelFanoutRecipients 都会把 1003 列进 recipients(在线活跃成员),
// 漏洞/修复的差异在 build 是否对它返回 nil。
got := r.channelFanoutRecipients(ctx, channelFanoutMembers, created.Channel.ID, res.Recipients)
if !fanoutHasID(got, 1003) {
t.Fatalf("channelFanoutRecipients = %v, 期望在线活跃成员 1003 仍被列入(否则测不到 build 跳过)", got)
}
r.enqueueChannelMessageFanout(ctx, 1001, res, nil)
pushed := sessions.pushedUserIDs()
if !fanoutHasID(pushed, 1002) {
t.Fatalf("fanout pushed = %v, want human member 1002 to receive online push", pushed)
}
if fanoutHasID(pushed, 1003) {
t.Fatalf("fanout pushed = %v, online privacy bot 1003 must NOT receive plain-message push (privacy leak)", pushed)
}
}

View file

@ -0,0 +1,162 @@
package rpc
import (
"context"
"fmt"
"time"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
const (
channelFullBotInfoCacheTTL = 30 * time.Minute
channelFullBotInfoCacheMaxEntries = 4096
)
type channelFullBotInfoCacheKey struct {
viewerUserID int64
channelID int64
}
// channelFullBotInfoCache 收敛到 projectionCache[K,V](epoch 守卫 / LRU / TTL / clone 由原语承载)。
// 它走 Router 级 singleflight + 外部构建再写回(LoadEpoch→loadChannelFullBotInfo→StoreIfEpoch)。
type channelFullBotInfoCache struct {
*projectionCache[channelFullBotInfoCacheKey, channelFullBotInfoResult]
}
type channelFullBotInfoResult struct {
userIDs []int64
botInfos []tg.BotInfo
}
func newChannelFullBotInfoCache(clock func() time.Time) *channelFullBotInfoCache {
return &channelFullBotInfoCache{
newProjectionCache[channelFullBotInfoCacheKey, channelFullBotInfoResult](channelFullBotInfoCacheMaxEntries, channelFullBotInfoCacheTTL, clock, cloneChannelFullBotInfoResult),
}
}
func cloneChannelFullBotInfoResult(in channelFullBotInfoResult) channelFullBotInfoResult {
return channelFullBotInfoResult{
userIDs: cloneInt64s(in.userIDs),
botInfos: cloneBotInfos(in.botInfos),
}
}
func channelFullBotInfoSingleflightKey(viewerUserID, channelID int64) string {
return fmt.Sprintf("%d:%d", viewerUserID, channelID)
}
func (c *channelFullBotInfoCache) Lookup(viewerUserID, channelID int64) (channelFullBotInfoResult, bool) {
if c == nil || viewerUserID == 0 || channelID == 0 {
return channelFullBotInfoResult{}, false
}
return c.lookup(channelFullBotInfoCacheKey{viewerUserID: viewerUserID, channelID: channelID})
}
// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。
func (c *channelFullBotInfoCache) StoreIfEpoch(viewerUserID, channelID int64, value channelFullBotInfoResult, loadEpoch uint64) {
if c == nil || viewerUserID == 0 || channelID == 0 {
return
}
c.storeIfEpoch(channelFullBotInfoCacheKey{viewerUserID: viewerUserID, channelID: channelID}, value, loadEpoch)
}
func (c *channelFullBotInfoCache) DeleteChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.deleteWhere(func(k channelFullBotInfoCacheKey) bool { return k.channelID == channelID })
}
func (r *Router) channelFullBotInfo(ctx context.Context, viewerUserID, channelID int64) channelFullBotInfoResult {
if r.deps.Channels == nil || viewerUserID == 0 || channelID == 0 {
return channelFullBotInfoResult{}
}
if cached, ok := r.channelFullBotCache.Lookup(viewerUserID, channelID); ok {
return cached
}
key := channelFullBotInfoSingleflightKey(viewerUserID, channelID)
value, _, _ := r.channelFullBotSF.Do(key, func() (any, error) {
if cached, ok := r.channelFullBotCache.Lookup(viewerUserID, channelID); ok {
return cached, nil
}
loadEpoch := r.channelFullBotCache.LoadEpoch()
result := r.loadChannelFullBotInfo(ctx, viewerUserID, channelID)
r.channelFullBotCache.StoreIfEpoch(viewerUserID, channelID, result, loadEpoch)
return result, nil
})
if result, ok := value.(channelFullBotInfoResult); ok {
return result
}
return channelFullBotInfoResult{}
}
func (r *Router) loadChannelFullBotInfo(ctx context.Context, viewerUserID, channelID int64) channelFullBotInfoResult {
list, err := r.deps.Channels.GetParticipants(ctx, viewerUserID, channelID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots}, 0, domain.MaxChannelParticipantsLimit)
if err != nil {
return channelFullBotInfoResult{}
}
userIDs := make([]int64, 0, len(list.Participants))
seen := make(map[int64]struct{}, len(list.Participants))
for _, member := range list.Participants {
if member.UserID == 0 {
continue
}
if _, ok := seen[member.UserID]; ok {
continue
}
seen[member.UserID] = struct{}{}
userIDs = append(userIDs, member.UserID)
}
return channelFullBotInfoResult{
userIDs: userIDs,
botInfos: r.tgBotInfos(ctx, userIDs),
}
}
func (r *Router) invalidateChannelFullBotInfoCacheForChannel(channelID int64) {
if r.channelFullBotCache != nil {
r.channelFullBotCache.DeleteChannel(channelID)
}
r.invalidateRPCProjectionForChannel(channelID)
}
func (r *Router) invalidateChannelFullBotInfoCache() {
if r.channelFullBotCache != nil {
r.channelFullBotCache.Clear()
}
if r.channelFullProjectionCache != nil {
r.channelFullProjectionCache.Clear()
}
}
func (r *Router) InvalidateChannelFullBotInfoReadModel(channelID int64) {
r.invalidateChannelFullBotInfoCacheForChannel(channelID)
}
func (r *Router) FlushChannelFullBotInfoReadModel() {
r.invalidateChannelFullBotInfoCache()
}
func cloneInt64s(in []int64) []int64 {
if len(in) == 0 {
return nil
}
out := make([]int64, len(in))
copy(out, in)
return out
}
func cloneBotInfos(in []tg.BotInfo) []tg.BotInfo {
if len(in) == 0 {
return nil
}
out := make([]tg.BotInfo, len(in))
copy(out, in)
for i := range out {
out[i].Commands = append([]tg.BotCommand(nil), out[i].Commands...)
}
return out
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
package rpc
import (
"context"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"testing"
)
func TestChannelAdminLogUsersUsesSingleBatchLookup(t *testing.T) {
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{
3: {ID: 3, FirstName: "Actor"},
4: {ID: 4, FirstName: "Participant"},
5: {ID: 5, FirstName: "Sender"},
}}}
r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
got := r.channelAdminLogUsers(context.Background(), 1, []domain.ChannelAdminLogEvent{
{
UserID: 3,
Participant: &domain.ChannelMember{
UserID: 4,
InviterUserID: 3,
},
Message: &domain.ChannelMessage{
SenderUserID: 5,
From: domain.Peer{Type: domain.PeerTypeUser, ID: 4},
},
},
{
UserID: 5,
PrevParticipant: &domain.ChannelMember{UserID: 4},
},
})
if users.byIDsCalls != 1 || users.byIDCalls != 0 {
t.Fatalf("user lookups byIDs=%d byID=%d, want one ByIDs and no ByID", users.byIDsCalls, users.byIDCalls)
}
if len(users.lastByIDs) != 3 || users.lastByIDs[0] != 3 || users.lastByIDs[1] != 4 || users.lastByIDs[2] != 5 {
t.Fatalf("ByIDs ids = %+v, want [3 4 5]", users.lastByIDs)
}
ids := gotUserIDs(got)
if len(ids) != 3 || ids[0] != 3 || ids[1] != 4 || ids[2] != 5 {
t.Fatalf("admin log users = %+v, want users 3/4/5", got)
}
}
func gotUserIDs(users []tg.UserClass) []int64 {
out := make([]int64, 0, len(users))
for _, item := range users {
if user, ok := item.(*tg.User); ok {
out = append(out, user.ID)
}
}
return out
}

View file

@ -0,0 +1,21 @@
package rpc
const (
maxChannelTitleLength = 128
maxChannelAboutLength = 255
maxChannelUsernameOrder = 32
maxChannelReportMessageIDs = 100
maxChannelSearchPostsLimit = 50
maxChannelSearchPostsQuery = 256
maxChannelPaidMessageStars = 10000
maxChannelBoostsToUnblockRestrictions = 8
maxChatInviteListLimit = 100
maxChatInviteLinkLength = 256
maxChatInviteSearchLength = 256
)
const (
channelFanoutMembers channelFanoutScope = iota
channelFanoutViewers
channelFanoutExplicit
)

View file

@ -0,0 +1,278 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
"unicode/utf8"
)
func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) {
if err := validateChannelsCreateChannelOptions(req); err != nil {
return nil, err
}
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if !validChannelTitle(req.Title) || utf8.RuneCountInString(req.About) > maxChannelAboutLength {
return nil, channelInvalidErr(domain.ErrChannelTitleInvalid)
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
res, err := r.deps.Channels.CreateChannel(ctx, userID, domain.CreateChannelRequest{
CreatorUserID: userID,
Title: req.Title,
About: req.About,
Broadcast: req.Broadcast,
Megagroup: req.Megagroup,
Forum: req.Forum,
ForumTabs: req.Forum,
TTLPeriod: req.TTLPeriod,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInvalidErr(err)
}
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
return updates, nil
}
func validateChannelsCreateChannelOptions(req *tg.ChannelsCreateChannelRequest) error {
if req == nil {
return inputRequestInvalidErr()
}
if req.ForImport {
return chatInvalidErr()
}
if req.GeoPoint != nil || req.Address != "" {
return addressInvalidErr()
}
if req.TTLPeriod < 0 {
return ttlPeriodInvalidErr()
}
return nil
}
func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChannelClass) (tg.MessagesChatsClass, error) {
if len(ids) > maxGetMessagesIDs {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
refs := make([]channelInputRef, 0, len(ids))
channelIDs := make([]int64, 0, len(ids))
for _, input := range ids {
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 || r.deps.Channels == nil {
continue
}
refs = append(refs, ref)
channelIDs = append(channelIDs, ref.ID)
}
if len(channelIDs) == 0 || r.deps.Channels == nil {
return &tg.MessagesChats{}, nil
}
views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
}
byID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
byID[view.Channel.ID] = view
}
chats := make([]tg.ChatClass, 0, len(refs))
for _, ref := range refs {
view, ok := byID[ref.ID]
if !ok || !inputChannelAccessHashMatches(ref, view.Channel) {
continue
}
chats = append(chats, tgChannelChatForView(userID, view))
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) {
if r.deps.Channels == nil {
return &tg.MessagesChatFull{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
ref, ok := inputChannelRef(input)
if !ok {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
loadEpoch := r.channelFullProjectionCache.LoadEpoch()
if cached, ok := r.channelFullProjectionCache.Lookup(userID, ref.ID); ok {
if !inputChannelAccessHashMatches(ref, domain.Channel{ID: ref.ID, AccessHash: cached.accessHash}) {
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
full := cached.full
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
chats := append([]tg.ChatClass(nil), cached.chats...)
r.trackChannelInterest(ctx, userID, ref.ID)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
FullChat: &full,
Chats: chats,
Users: r.tgUsersForIDs(ctx, userID, cached.userIDs),
}, nil
}
view, err := r.channelFullReadView(ctx, userID, input)
if err != nil {
return nil, err
}
full := tgChannelFull(view)
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
// 注Bots 过滤实际会返回群内 botTestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
// (此前审计误判为死代码,已由单测纠正——勿删。)
botInfo := r.channelFullBotInfo(ctx, userID, view.Channel.ID)
userIDs = append(userIDs, botInfo.userIDs...)
full.BotInfo = append(full.BotInfo, botInfo.botInfos...)
if canViewChannelJoinRequests(view.Self) {
userIDs = r.applyPendingJoinRequestsToFullChannel(ctx, full, view.Channel.ID, userIDs)
}
r.trackChannelInterest(ctx, userID, view.Channel.ID)
chats := []tg.ChatClass{tgChannelChatForView(userID, view)}
if mono, ok := r.linkedMonoforumForChannelState(ctx, userID, view.Channel); ok {
chats = appendUniqueTGChats(chats, tgChannelChat(userID, mono, nil))
}
// 当前频道默认已由 tgChannelFull 处理;外部频道默认(以自己拥有的别的频道身份发言)需在此投影并
// 带上该频道对象,否则客户端拿不到默认 chip。
r.applyForeignDefaultSendAsToFull(ctx, userID, view, full, &chats)
r.channelFullProjectionCache.StoreIfEpoch(userID, view.Channel.ID, channelFullProjection{
accessHash: view.Channel.AccessHash,
full: *full,
chats: append([]tg.ChatClass(nil), chats...),
userIDs: userIDs,
}, loadEpoch)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
FullChat: full,
Chats: chats,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
}, nil
}
func (r *Router) applyStarGiftsCountToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
if r.deps.Gifts == nil || channelID == 0 || full == nil {
return
}
n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
if err == nil && n > 0 {
full.SetStargiftsCount(n)
}
}
type channelReadModelResolver interface {
GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
}
func (r *Router) channelFullReadView(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.ChannelView, error) {
ref, ok := inputChannelRef(input)
if !ok {
return domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid)
}
var (
view domain.ChannelView
err error
)
if cached, ok := r.deps.Channels.(channelReadModelResolver); ok {
view, err = cached.GetChannelReadModel(ctx, userID, ref.ID)
} else {
view, err = r.deps.Channels.GetChannel(ctx, userID, ref.ID)
}
if err != nil {
return domain.ChannelView{}, channelInvalidErr(err)
}
if !inputChannelAccessHashMatches(ref, view.Channel) {
return domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate)
}
return view, nil
}
func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSendAsRequest) (*tg.ChannelsSendAsPeers, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, peerIDInvalidErr()
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
chats := []tg.ChatClass(nil)
peers := []tg.SendAsPeer{{Peer: &tg.PeerUser{UserID: userID}}}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return &tg.ChannelsSendAsPeers{}, nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
if ref, ok := inputPeerChannelRef(req.Peer); ok {
if ref.ID != view.Channel.ID || (ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, view.Channel)) {
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
}
chats = []tg.ChatClass{tgChannelChatForView(userID, view)}
// 以「当前频道/群本身」发言广播频道自帖、匿名管理员等canCurrentChannelSendAs 判定)。
if canCurrentChannelSendAs(view) {
peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}})
}
// 以「用户自己拥有的其它广播频道」身份在本群发言。非本群关联频道的个人频道需会员
// premium_required对齐官方仅本群的 linked 讨论频道免会员),客户端据此置灰/引导开会员,
// 服务端在发送侧用 PremiumActiveAt 兜底门控。
if owned, err := r.deps.Channels.ListSendAsChannels(ctx, userID); err == nil && len(owned) > 0 {
extras := make([]domain.Channel, 0, len(owned))
for _, ch := range owned {
if ch.ID == 0 || ch.ID == view.Channel.ID {
continue
}
sendAs := tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}}
if ch.ID != view.Channel.LinkedChatID {
sendAs.PremiumRequired = true
}
peers = append(peers, sendAs)
extras = append(extras, ch)
}
chats = append(chats, tgChannels(userID, extras)...)
}
}
return &tg.ChannelsSendAsPeers{
Peers: peers,
Chats: chats,
Users: r.tgUsersForIDs(ctx, userID, []int64{userID}),
}, nil
}
func (r *Router) applyPendingJoinRequestsToFullChannel(ctx context.Context, full *tg.ChannelFull, channelID int64, userIDs []int64) []int64 {
if r.deps.Channels == nil || full == nil || channelID == 0 {
return userIDs
}
pending, err := r.deps.Channels.PendingJoinRequests(ctx, channelID, domain.MaxChannelPendingJoinRecentRequesters)
if err != nil || pending.Count <= 0 {
return userIDs
}
full.SetRequestsPending(pending.Count)
full.SetRecentRequesters(pending.RecentRequesters)
return append(userIDs, pending.RecentRequesters...)
}

View file

@ -0,0 +1,147 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestChannelsDeleteChannelCascadesMonoforumForbiddenRPC 锁定 Risk A 的 RPC 层契约:删除开启了
// Direct Messages 的母广播频道时,响应与推送都必须为母频道 **和** 其关联 monoforum 各下发一条
// ChannelForbidden 墓碑,且删后 getDialogs 两者都不再返回。否则在线客户端会留着 mono 会话
// (isMonoforum=true 但 link 不可解析)继续渲染崩溃。
func TestChannelsDeleteChannelCascadesMonoforumForbiddenRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550004041", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
bc, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
parentID := bc.Channel.ID
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, parentID, 0, true)
if err != nil {
t.Fatalf("enable DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
if monoID == 0 {
t.Fatalf("no monoforum created")
}
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
getDialogChannelIDs := func() map[int64]struct{} {
t.Helper()
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 50}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
box, ok := enc.(*tg.MessagesDialogsBox)
if !ok {
t.Fatalf("dialogs response = %T, want box", enc)
}
ids := map[int64]struct{}{}
switch d := box.Dialogs.(type) {
case *tg.MessagesDialogs:
for _, ch := range d.Chats {
if c, ok := ch.(*tg.Channel); ok {
ids[c.ID] = struct{}{}
}
}
case *tg.MessagesDialogsSlice:
for _, ch := range d.Chats {
if c, ok := ch.(*tg.Channel); ok {
ids[c.ID] = struct{}{}
}
}
default:
t.Fatalf("dialogs = %T, want messages.dialogs(Slice)", box.Dialogs)
}
return ids
}
before := getDialogChannelIDs()
if _, ok := before[parentID]; !ok {
t.Fatalf("before delete: parent %d missing from dialogs %v", parentID, before)
}
if _, ok := before[monoID]; !ok {
t.Fatalf("before delete: monoforum %d missing from dialogs %v", monoID, before)
}
deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{
ChannelID: parentID,
AccessHash: bc.Channel.AccessHash,
})
if err != nil {
t.Fatalf("delete channel: %v", err)
}
forbidden := func(updates *tg.Updates) map[int64]struct{} {
ids := map[int64]struct{}{}
for _, ch := range updates.Chats {
if f, ok := ch.(*tg.ChannelForbidden); ok {
ids[f.ID] = struct{}{}
}
}
return ids
}
respUpdates, ok := deleted.(*tg.Updates)
if !ok {
t.Fatalf("delete response = %T, want *tg.Updates", deleted)
}
respForbidden := forbidden(respUpdates)
if _, ok := respForbidden[parentID]; !ok {
t.Fatalf("delete response missing ChannelForbidden for parent %d: %+v", parentID, respUpdates.Chats)
}
if _, ok := respForbidden[monoID]; !ok {
t.Fatalf("delete response missing ChannelForbidden for monoforum %d: %+v", monoID, respUpdates.Chats)
}
// 推送给接收方(含 owner 本人)的更新同样要带 mono 墓碑。
pushed := sessions.snapshot()
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok {
t.Fatalf("pushed message = %T, want *tg.Updates", pushed.message)
}
pushedForbidden := forbidden(pushedUpdates)
if _, ok := pushedForbidden[monoID]; !ok {
t.Fatalf("pushed update missing ChannelForbidden for monoforum %d: %+v", monoID, pushedUpdates.Chats)
}
after := getDialogChannelIDs()
if _, ok := after[parentID]; ok {
t.Fatalf("after delete: parent %d still in dialogs %v", parentID, after)
}
if _, ok := after[monoID]; ok {
t.Fatalf("after delete: monoforum %d still in dialogs %v", monoID, after)
}
}

View file

@ -0,0 +1,75 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onChannelsGetLeftChannels(ctx context.Context, offset int) (tg.MessagesChatsClass, error) {
if offset < 0 || offset > domain.MaxLeftChannelsOffset {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, err
}
if r.deps.Channels == nil {
return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil
}
list, err := r.deps.Channels.LeftChannels(ctx, userID, offset, domain.MaxLeftChannelsLimit)
if err != nil {
return nil, channelInvalidErr(err)
}
chats := make([]tg.ChatClass, 0, len(list.Channels))
for _, item := range list.Channels {
chats = append(chats, tgChannelChat(userID, item.Channel, &item.Self))
}
if len(chats) == 0 && list.Count > 0 {
return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil
}
if offset+len(chats) < list.Count {
return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil
}
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onChannelsGetInactiveChannels(ctx context.Context) (*tg.MessagesInactiveChats, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, err
}
if r.deps.Channels == nil {
return &tg.MessagesInactiveChats{Dates: []int{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
}
list, err := r.deps.Channels.InactiveChannels(ctx, userID, domain.MaxInactiveChannelsLimit)
if err != nil {
return nil, channelInvalidErr(err)
}
dates := make([]int, 0, len(list.Channels))
chats := make([]tg.ChatClass, 0, len(list.Channels))
for i, channel := range list.Channels {
date := channel.Date
if i < len(list.Dialogs) && list.Dialogs[i].TopMessageDate > 0 {
date = list.Dialogs[i].TopMessageDate
}
dates = append(dates, date)
chats = append(chats, tgChannelChatMin(userID, channel))
}
return &tg.MessagesInactiveChats{Dates: dates, Chats: chats, Users: []tg.UserClass{}}, nil
}
func (r *Router) onChannelsGetGroupsForDiscussion(ctx context.Context) (tg.MessagesChatsClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Channels == nil {
return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil
}
channels, err := r.deps.Channels.DiscussionGroups(ctx, userID, domain.MaxDiscussionGroupsLimit)
if err != nil {
return nil, channelInvalidErr(err)
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
}

View file

@ -0,0 +1,605 @@
package rpc
import (
"context"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"strconv"
"strings"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestChannelDialogCarriesChannelPts(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001151", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Pts Group",
Megagroup: true,
Date: 1000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 7001,
Message: "pts probe",
Date: 1100,
})
if err != nil {
t.Fatalf("send message: %v", err)
}
wantPts := sent.Event.Pts
if wantPts <= 0 {
t.Fatalf("sent channel pts = %d, want > 0", wantPts)
}
dispatch := func(req bin.Encoder) bin.Encoder {
t.Helper()
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode request: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
return enc
}
got := dispatch(&tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20})
box, ok := got.(*tg.MessagesDialogsBox)
if !ok {
t.Fatalf("dialogs response = %T, want box", got)
}
dialogs, ok := box.Dialogs.(*tg.MessagesDialogs)
if !ok || len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %T %+v, want one channel dialog", box.Dialogs, box.Dialogs)
}
dialog, ok := dialogs.Dialogs[0].(*tg.Dialog)
if !ok {
t.Fatalf("dialog = %T, want *tg.Dialog", dialogs.Dialogs[0])
}
pts, ok := dialog.GetPts()
if !ok || pts != wantPts {
t.Fatalf("dialog pts = %d (set=%v), want %d: clients seed channel difference state from dialog.pts", pts, ok, wantPts)
}
peerResp := dispatch(&tg.MessagesGetPeerDialogsRequest{
Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: &tg.InputPeerChannel{
ChannelID: created.Channel.ID,
AccessHash: created.Channel.AccessHash,
}}},
})
peerDialogs, ok := peerResp.(*tg.MessagesPeerDialogs)
if !ok || len(peerDialogs.Dialogs) != 1 {
t.Fatalf("peer dialogs = %T %+v, want one dialog", peerResp, peerResp)
}
peerDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog)
if !ok {
t.Fatalf("peer dialog = %T, want *tg.Dialog", peerDialogs.Dialogs[0])
}
if pts, ok := peerDialog.GetPts(); !ok || pts != wantPts {
t.Fatalf("peer dialog pts = %d (set=%v), want %d", pts, ok, wantPts)
}
}
func TestChannelsGetInactiveChannelsReturnsLeastActiveRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
heir, err := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550001042", FirstName: "Heir"})
if err != nil {
t.Fatalf("create heir: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
}, zaptest.NewLogger(t), clock.System)
createAndSend := func(title string, createDate, msgDate int, memberIDs ...int64) int64 {
t.Helper()
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: title,
Megagroup: true,
MemberUserIDs: memberIDs,
Date: createDate,
})
if err != nil {
t.Fatalf("create %s: %v", title, err)
}
if _, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: int64(msgDate),
Message: title + " message",
Date: msgDate,
}); err != nil {
t.Fatalf("send %s: %v", title, err)
}
return created.Channel.ID
}
oldID := createAndSend("Old inactive", 1000, 1100)
midID := createAndSend("Middle inactive", 1000, 1200)
newID := createAndSend("New inactive", 1000, 1300)
leftID := createAndSend("Left inactive", 1000, 900, heir.ID)
if _, err := channelSvc.LeaveChannel(ctx, owner.ID, leftID, 1400); err != nil {
t.Fatalf("leave channel: %v", err)
}
got, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID))
if err != nil {
t.Fatalf("get inactive channels: %v", err)
}
if len(got.Dates) != 3 || len(got.Chats) != 3 || len(got.Users) != 0 {
t.Fatalf("inactive chats = %+v, want three active channel chats and no users", got)
}
wantIDs := []int64{oldID, midID, newID}
wantDates := []int{1100, 1200, 1300}
for i, chat := range got.Chats {
channel, ok := chat.(*tg.Channel)
if !ok {
t.Fatalf("chat %d = %T, want *tg.Channel", i, chat)
}
if channel.ID != wantIDs[i] || got.Dates[i] != wantDates[i] {
t.Fatalf("inactive item %d = id %d date %d, want id %d date %d", i, channel.ID, got.Dates[i], wantIDs[i], wantDates[i])
}
}
}
func TestChannelsGetChannelRecommendationsRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
other, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", 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)
createPublicBroadcast := func(creator domain.User, title, username string, date int) domain.Channel {
t.Helper()
created, err := channelSvc.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{
Title: title,
Broadcast: true,
Date: date,
})
if err != nil {
t.Fatalf("create %s: %v", title, err)
}
channel, err := channelSvc.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{
ChannelID: created.Channel.ID,
Username: username,
})
if err != nil {
t.Fatalf("set username for %s: %v", title, err)
}
return channel
}
source := createPublicBroadcast(owner, "Source Recommendations", "source_recs", 1000)
for i := 0; i < 12; i++ {
createPublicBroadcast(owner, "Candidate "+strconv.Itoa(i), "rec"+strconv.Itoa(i)+"public", 2000+i)
}
groupCreated, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Public Group",
Megagroup: true,
Date: 3000,
})
if err != nil {
t.Fatalf("create public group: %v", err)
}
group, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
ChannelID: groupCreated.Channel.ID,
Username: "group_recs",
})
if err != nil {
t.Fatalf("set group username: %v", err)
}
recommendationsReq := func(channel domain.Channel) *tg.ChannelsGetChannelRecommendationsRequest {
req := &tg.ChannelsGetChannelRecommendationsRequest{}
req.SetChannel(&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
return req
}
got, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(source))
if err != nil {
t.Fatalf("get recommendations by source: %v", err)
}
slice, ok := got.(*tg.MessagesChatsSlice)
if !ok {
t.Fatalf("recommendations = %T %+v, want messages.chatsSlice", got, got)
}
if slice.Count != 12 || len(slice.Chats) != domain.DefaultChannelRecommendationsLimit {
t.Fatalf("recommendations count=%d len=%d, want count 12 len %d", slice.Count, len(slice.Chats), domain.DefaultChannelRecommendationsLimit)
}
for _, chat := range slice.Chats {
channel, ok := chat.(*tg.Channel)
if !ok {
t.Fatalf("recommendation chat = %T, want channel", chat)
}
if channel.ID == source.ID || !channel.Broadcast || channel.Megagroup || channel.Username == "" {
t.Fatalf("recommendation channel = %+v, want public broadcast excluding source", channel)
}
}
if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(group)); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") {
t.Fatalf("megagroup recommendations err = %v, want CHANNEL_INVALID", err)
}
globalA := createPublicBroadcast(other, "Global A", "global_recs_a", 5000)
globalB := createPublicBroadcast(other, "Global B", "global_recs_b", 5100)
global, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), &tg.ChannelsGetChannelRecommendationsRequest{})
if err != nil {
t.Fatalf("get global recommendations: %v", err)
}
box, ok := global.(*tg.MessagesChats)
if !ok {
t.Fatalf("global recommendations = %T %+v, want messages.chats", global, global)
}
if len(box.Chats) != 2 {
t.Fatalf("global recommendations len=%d chats=%+v, want two channels", len(box.Chats), box.Chats)
}
wantIDs := []int64{globalB.ID, globalA.ID}
for i, chat := range box.Chats {
channel, ok := chat.(*tg.Channel)
if !ok {
t.Fatalf("global chat %d = %T, want channel", i, chat)
}
if channel.ID != wantIDs[i] {
t.Fatalf("global chat %d id=%d, want %d", i, channel.ID, wantIDs[i])
}
}
}
func TestChannelsGetLeftChannelsRPCReturnsLeftFlagAndSafePaging(t *testing.T) {
ctx := context.Background()
const (
ownerID int64 = 1000000901
memberID int64 = 1000000902
)
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System)
older, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{
Title: "Older Left",
MemberUserIDs: []int64{memberID},
Date: 1700000900,
})
if err != nil {
t.Fatalf("create older megagroup: %v", err)
}
newer, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
Title: "Newer Left Broadcast",
Broadcast: true,
MemberUserIDs: []int64{memberID},
Date: 1700000901,
})
if err != nil {
t.Fatalf("create newer broadcast: %v", err)
}
if _, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{
Title: "Active Excluded",
MemberUserIDs: []int64{memberID},
Date: 1700000902,
}); err != nil {
t.Fatalf("create active megagroup: %v", err)
}
if _, err := channelService.LeaveChannel(ctx, memberID, older.Channel.ID, 1700000903); err != nil {
t.Fatalf("leave older channel: %v", err)
}
if _, err := channelService.LeaveChannel(ctx, memberID, newer.Channel.ID, 1700000904); err != nil {
t.Fatalf("leave newer channel: %v", err)
}
got, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 0)
if err != nil {
t.Fatalf("get left channels: %v", err)
}
chats, ok := got.(*tg.MessagesChats)
if !ok || len(chats.Chats) != 2 {
t.Fatalf("left channels = %T %+v, want final messages.chats with two chats", got, got)
}
first, ok := chats.Chats[0].(*tg.Channel)
if !ok || first.ID != newer.Channel.ID || !first.Left {
t.Fatalf("first left channel = %+v (%T), want newest with left flag", chats.Chats[0], chats.Chats[0])
}
second, ok := chats.Chats[1].(*tg.Channel)
if !ok || second.ID != older.Channel.ID || !second.Left {
t.Fatalf("second left channel = %+v (%T), want older with left flag", chats.Chats[1], chats.Chats[1])
}
empty, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 2)
if err != nil {
t.Fatalf("get empty left channels page: %v", err)
}
emptySlice, ok := empty.(*tg.MessagesChatsSlice)
if !ok || emptySlice.Count != 2 || len(emptySlice.Chats) != 0 {
t.Fatalf("empty left page = %T %+v, want empty slice with full count", empty, empty)
}
if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), domain.MaxLeftChannelsOffset+1); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("huge offset err = %v, want LIMIT_INVALID", err)
}
}
func TestChannelsDiscussionGroupRPCPersistsFullChannelLink(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1000000911
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System)
broadcast, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
Title: "Discussion Broadcast",
Broadcast: true,
Date: 1700000910,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
group, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{
Title: "Discussion Group",
Date: 1700000911,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}
inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}
candidates, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, ownerID))
if err != nil {
t.Fatalf("get groups for discussion: %v", err)
}
candidateChats := candidates.(*tg.MessagesChats).Chats
if len(candidateChats) != 1 || candidateChats[0].(*tg.Channel).ID != group.Channel.ID {
t.Fatalf("discussion candidates = %+v, want the creator megagroup", candidateChats)
}
if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{
Broadcast: inputBroadcast,
Group: inputGroup,
}); err != nil || !ok {
t.Fatalf("set discussion group = ok %v err %v, want true", ok, err)
}
fullBroadcast, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast)
if err != nil {
t.Fatalf("get full broadcast: %v", err)
}
linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID()
if !ok || linkedID != group.Channel.ID {
t.Fatalf("broadcast linked_chat_id = %d ok %v, want group %d", linkedID, ok, group.Channel.ID)
}
fullGroup, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputGroup)
if err != nil {
t.Fatalf("get full group: %v", err)
}
groupLinkedID, ok := fullGroup.FullChat.(*tg.ChannelFull).GetLinkedChatID()
if !ok || groupLinkedID != broadcast.Channel.ID {
t.Fatalf("group linked_chat_id = %d ok %v, want broadcast %d", groupLinkedID, ok, broadcast.Channel.ID)
}
gotChannel := fullBroadcast.Chats[0].(*tg.Channel)
if !gotChannel.GetHasLink() {
t.Fatalf("broadcast channel = %+v, want has_link", gotChannel)
}
if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{
Broadcast: &tg.InputChannelEmpty{},
Group: inputGroup,
}); err != nil || !ok {
t.Fatalf("unlink discussion group from group side = ok %v err %v, want true", ok, err)
}
fullBroadcast, err = r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast)
if err != nil {
t.Fatalf("get full broadcast after unlink: %v", err)
}
if linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID(); ok || linkedID != 0 {
t.Fatalf("broadcast linked_chat_id after unlink = %d ok %v, want unset", linkedID, ok)
}
if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{
Broadcast: &tg.InputChannelEmpty{},
Group: inputGroup,
}); err == nil || !strings.Contains(err.Error(), "LINK_NOT_MODIFIED") {
t.Fatalf("repeat unlink err = %v, want LINK_NOT_MODIFIED", err)
}
if _, err := channelService.SetPreHistoryHidden(ctx, ownerID, group.Channel.ID, true); err != nil {
t.Fatalf("hide group prehistory: %v", err)
}
if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{
Broadcast: inputBroadcast,
Group: inputGroup,
}); err == nil || !strings.Contains(err.Error(), "MEGAGROUP_PREHISTORY_HIDDEN") {
t.Fatalf("hidden group link err = %v, want MEGAGROUP_PREHISTORY_HIDDEN", err)
}
}
func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002911", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550002912", FirstName: "Member"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
}, zaptest.NewLogger(t), clock.System)
broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Discussion Source",
Broadcast: true,
Date: 1700002911,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
group, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Discussion Replies",
MemberUserIDs: []int64{member.ID},
Date: 1700002912,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}
inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}
if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{
Broadcast: inputBroadcast,
Group: inputGroup,
}); err != nil || !ok {
t.Fatalf("set discussion group = ok %v err %v, want true", ok, err)
}
postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
Message: "channel post",
RandomID: 2911001,
})
if err != nil {
t.Fatalf("send broadcast post: %v", err)
}
post := postUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if !post.Post {
t.Fatalf("broadcast post = %#v, want channel post", post)
}
discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
})
if err != nil {
t.Fatalf("get discussion message: %v", err)
}
if len(discussion.Messages) != 1 || len(discussion.Chats) != 2 {
t.Fatalf("discussion = %+v, want linked root message with source and group chats", discussion)
}
root, ok := discussion.Messages[0].(*tg.Message)
if !ok {
t.Fatalf("discussion root = %T, want message", discussion.Messages[0])
}
if peer, ok := root.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID {
t.Fatalf("discussion root peer = %#v, want linked group %d", root.PeerID, group.Channel.ID)
}
if from, ok := root.FromID.(*tg.PeerChannel); !ok || from.ChannelID != broadcast.Channel.ID {
t.Fatalf("discussion root from = %#v, want source channel %d", root.FromID, broadcast.Channel.ID)
}
fwd, ok := root.GetFwdFrom()
if !ok {
t.Fatalf("discussion root fwd_from missing")
}
if channelPost, ok := fwd.GetChannelPost(); !ok || channelPost != post.ID {
t.Fatalf("discussion root channel_post = %d ok %v, want %d", channelPost, ok, post.ID)
}
if savedMsgID, ok := fwd.GetSavedFromMsgID(); !ok || savedMsgID != post.ID {
t.Fatalf("discussion root saved_from_msg_id = %d ok %v, want %d", savedMsgID, ok, post.ID)
}
if savedPeer, ok := fwd.GetSavedFromPeer(); !ok {
t.Fatalf("discussion root saved_from_peer missing")
} else if savedChannel, ok := savedPeer.(*tg.PeerChannel); !ok || savedChannel.ChannelID != broadcast.Channel.ID {
t.Fatalf("discussion root saved_from_peer = %#v, want source channel %d", savedPeer, broadcast.Channel.ID)
}
replyTo := &tg.InputReplyToMessage{ReplyToMsgID: root.ID}
replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), func() *tg.MessagesSendMessageRequest {
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash},
Message: "discussion reply",
RandomID: 2911002,
}
req.SetReplyTo(replyTo)
return req
}())
if err != nil {
t.Fatalf("send discussion reply: %v", err)
}
comment := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("get replies: %v", err)
}
replyMessages, replyChats, _ := searchMessagesPayload(t, replies)
if len(replyMessages) != 1 || len(replyChats) != 2 {
t.Fatalf("get replies = %T %+v, want one linked group reply with both channel contexts", replies, replies)
}
gotReply := replyMessages[0].(*tg.Message)
if gotReply.ID != comment.ID || gotReply.Message != "discussion reply" {
t.Fatalf("reply message = %#v, want comment id %d", gotReply, comment.ID)
}
if peer, ok := gotReply.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID {
t.Fatalf("reply peer = %#v, want linked group %d", gotReply.PeerID, group.Channel.ID)
}
header, ok := gotReply.ReplyTo.(*tg.MessageReplyHeader)
if !ok {
t.Fatalf("reply header = %#v, want messageReplyHeader", gotReply.ReplyTo)
}
topID, topOK := header.GetReplyToTopID()
if header.ReplyToMsgID != root.ID || !topOK || topID != root.ID {
t.Fatalf("reply header = %#v, want msg/top %d", header, root.ID)
}
views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
ID: []int{post.ID},
})
if err != nil {
t.Fatalf("get message views: %v", err)
}
replyInfo, ok := views.Views[0].GetReplies()
if !ok || !replyInfo.Comments || replyInfo.Replies != 1 {
t.Fatalf("message views replies = %+v ok %v, want one comment", replyInfo, ok)
}
if channelID, ok := replyInfo.GetChannelID(); !ok || channelID != group.Channel.ID {
t.Fatalf("message views channel_id = %d ok %v, want %d", channelID, ok, group.Channel.ID)
}
if maxID, ok := replyInfo.GetMaxID(); !ok || maxID != comment.ID {
t.Fatalf("message views max_id = %d ok %v, want %d", maxID, ok, comment.ID)
}
if ok, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
ReadMaxID: comment.ID,
}); err != nil || !ok {
t.Fatalf("read discussion = ok %v err %v, want true", ok, err)
}
afterRead, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
})
if err != nil {
t.Fatalf("get discussion after read: %v", err)
}
if afterRead.ReadInboxMaxID != comment.ID || afterRead.UnreadCount != 0 {
t.Fatalf("discussion after read = %+v, want read inbox %d and no unread", afterRead, comment.ID)
}
}

View file

@ -0,0 +1,148 @@
package rpc
import (
"context"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"strings"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestImportChatInviteErrorsRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002258", FirstName: "Owner"})
first, _ := userStore.Create(ctx, domain.User{AccessHash: 59, Phone: "15550002259", FirstName: "First"})
second, _ := userStore.Create(ctx, domain.User{AccessHash: 60, Phone: "15550002260", FirstName: "Second"})
channelStore := memory.NewChannelStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "RPC Import Errors",
Megagroup: true,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
input := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
requestInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{
Peer: input,
Title: "approval",
RequestNeeded: true,
})
if err != nil {
t.Fatalf("export request-needed invite: %v", err)
}
requestHash := strings.TrimPrefix(requestInvite.(*tg.ChatInviteExported).Link, "https://telesrv.net/+")
if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") {
t.Fatalf("import request-needed err = %v, want INVITE_REQUEST_SENT", err)
}
pushedPending := sessions.snapshot()
if pushedPending.userID != owner.ID || pushedPending.messageType != proto.MessageFromServer {
t.Fatalf("pending request push = %+v, want owner server update", pushedPending)
}
pushedPendingUpdates, ok := pushedPending.message.(*tg.Updates)
if !ok || len(pushedPendingUpdates.Updates) != 1 {
t.Fatalf("pending request push message = %T %+v, want one update", pushedPending.message, pushedPending.message)
}
pushedPendingUpdate, ok := pushedPendingUpdates.Updates[0].(*tg.UpdatePendingJoinRequests)
if !ok || pushedPendingUpdate.RequestsPending != 1 || len(pushedPendingUpdate.RecentRequesters) != 1 || pushedPendingUpdate.RecentRequesters[0] != first.ID {
t.Fatalf("pending request update = %+v, want first requester", pushedPendingUpdates.Updates[0])
}
fullAfterPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), inputChannel)
if err != nil {
t.Fatalf("get full channel after pending request: %v", err)
}
fullPending := fullAfterPending.FullChat.(*tg.ChannelFull)
requestsPending, ok := fullPending.GetRequestsPending()
recentRequesters, recentOK := fullPending.GetRecentRequesters()
if !ok || requestsPending != 1 || !recentOK || len(recentRequesters) != 1 || recentRequesters[0] != first.ID {
t.Fatalf("full pending = count %d ok %v recent %+v ok %v, want first requester", requestsPending, ok, recentRequesters, recentOK)
}
pendingReq := &tg.MessagesGetChatInviteImportersRequest{
Requested: true,
Peer: input,
Limit: 10,
}
pendingReq.SetLink(requestInvite.(*tg.ChatInviteExported).Link)
pending, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq)
if err != nil {
t.Fatalf("get pending invite importers: %v", err)
}
if pending.Count != 1 || len(pending.Importers) != 1 || pending.Importers[0].UserID != first.ID || !pending.Importers[0].Requested {
t.Fatalf("pending importers = %+v, want first pending request", pending)
}
limitedInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{
Peer: input,
Title: "one",
UsageLimit: 1,
})
if err != nil {
t.Fatalf("export limited invite: %v", err)
}
limitedHash := strings.TrimPrefix(limitedInvite.(*tg.ChatInviteExported).Link, "https://telesrv.net/+")
if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), limitedHash); err != nil {
t.Fatalf("first import limited invite: %v", err)
}
pendingAfterJoin, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq)
if err != nil {
t.Fatalf("get pending invite importers after join: %v", err)
}
if pendingAfterJoin.Count != 0 || len(pendingAfterJoin.Importers) != 0 {
t.Fatalf("pending importers after alternate invite join = %+v, want cleared", pendingAfterJoin)
}
if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), limitedHash); err == nil || !strings.Contains(err.Error(), "USERS_TOO_MUCH") {
t.Fatalf("second import limited err = %v, want USERS_TOO_MUCH", err)
}
if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") {
t.Fatalf("second import request-needed err = %v, want INVITE_REQUEST_SENT", err)
}
pendingSecond, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq)
if err != nil {
t.Fatalf("get second pending invite importers: %v", err)
}
if pendingSecond.Count != 1 || len(pendingSecond.Importers) != 1 || pendingSecond.Importers[0].UserID != second.ID || !pendingSecond.Importers[0].Requested {
t.Fatalf("second pending importers = %+v, want second pending request", pendingSecond)
}
approved, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{
Approved: true,
Peer: input,
UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash},
})
if err != nil {
t.Fatalf("approve chat join request: %v", err)
}
if updates := approved.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) == 0 {
t.Fatalf("approve join request updates = %+v, want channel updates", updates)
}
approvedUpdates := approved.(*tg.Updates)
var pendingCleared *tg.UpdatePendingJoinRequests
for _, update := range approvedUpdates.Updates {
if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok {
pendingCleared = pending
break
}
}
if pendingCleared == nil || pendingCleared.RequestsPending != 0 || len(pendingCleared.RecentRequesters) != 0 {
t.Fatalf("approve pending update = %+v, want cleared pending requests", pendingCleared)
}
if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{
Approved: true,
Peer: input,
UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash},
}); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") {
t.Fatalf("approve missing join request err = %v, want HIDE_REQUESTER_MISSING", err)
}
}

View file

@ -0,0 +1,447 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"strings"
"telesrv/internal/domain"
)
func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.MessagesExportChatInviteRequest) (tg.ExportedChatInviteClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
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 || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
if req.UsageLimit < 0 || req.ExpireDate < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength {
return nil, limitInvalidErr()
}
res, err := r.deps.Channels.ExportInvite(ctx, userID, domain.ExportChannelInviteRequest{
UserID: userID,
ChannelID: peer.ID,
Title: req.Title,
RequestNeeded: req.RequestNeeded,
ExpireDate: req.ExpireDate,
UsageLimit: req.UsageLimit,
LegacyRevokePermanent: req.LegacyRevokePermanent,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
return tgExportedChannelInvite(res.Invite), nil
}
func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
res, err := r.deps.Channels.CheckInvite(ctx, userID, hash, int(r.clock.Now().Unix()))
if err != nil {
return nil, channelInviteErr(err)
}
if res.Already {
return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil
}
return &tg.ChatInvite{
Channel: true,
Broadcast: res.Channel.Broadcast,
Megagroup: res.Channel.Megagroup,
Public: res.Channel.Username != "",
RequestNeeded: res.Invite.RequestNeeded,
Title: res.Channel.Title,
About: res.Channel.About,
Photo: &tg.PhotoEmpty{},
ParticipantsCount: res.Channel.ParticipantsCount,
}, nil
}
func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.MessagesChatInviteJoinResultClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
res, err := r.deps.Channels.ImportInvite(ctx, userID, domain.ImportChannelInviteRequest{
UserID: userID,
Hash: hash,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 {
r.pushPendingJoinRequestsToAdmins(ctx, res.Channel)
}
return nil, channelInviteErr(err)
}
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
// Layer 227messages.importChatInvite 返回 messages.ChatInviteJoinResult
// 正常加入即 chatInviteJoinResultOk 包裹本次操作的 updates。
return &tg.MessagesChatInviteJoinResultOk{Updates: updates}, nil
}
func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.MessagesGetExportedChatInvitesRequest) (*tg.MessagesExportedChatInvites, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(req.OffsetLink) > maxChatInviteLinkLength {
return nil, limitInvalidErr()
}
adminID := userID
if !inputUserIsEmpty(req.AdminID) {
admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID})
if err != nil {
return nil, err
}
if len(admins) > 0 {
adminID = admins[0]
}
}
offsetHash := ""
if req.OffsetLink != "" {
offsetHash, err = channelInviteHashFromLink(req.OffsetLink)
if err != nil {
return nil, err
}
}
list, err := r.deps.Channels.ListExportedInvites(ctx, userID, domain.ChannelInviteListRequest{
UserID: userID,
ChannelID: view.Channel.ID,
AdminUserID: adminID,
Revoked: req.Revoked,
OffsetDate: req.OffsetDate,
OffsetHash: offsetHash,
Limit: req.Limit,
})
if err != nil {
return nil, channelInviteErr(err)
}
userIDs := []int64{adminID}
invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites))
for _, invite := range list.Invites {
invites = append(invites, tgExportedChannelInvite(invite))
userIDs = append(userIDs, invite.AdminUserID)
}
return &tg.MessagesExportedChatInvites{
Count: list.Count,
Invites: invites,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
}, nil
}
func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.MessagesGetExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
hash, err := channelInviteHashFromLink(req.Link)
if err != nil {
return nil, err
}
invite, err := r.deps.Channels.GetExportedInvite(ctx, userID, domain.GetChannelInviteRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
})
if err != nil {
return nil, channelInviteErr(err)
}
return &tg.MessagesExportedChatInvite{
Invite: tgExportedChannelInvite(invite),
Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}),
}, nil
}
func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.MessagesEditExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
hash, err := channelInviteHashFromLink(req.Link)
if err != nil {
return nil, err
}
if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength {
return nil, limitInvalidErr()
}
expireDate, hasExpireDate := req.GetExpireDate()
usageLimit, hasUsageLimit := req.GetUsageLimit()
requestNeeded, hasRequestNeeded := req.GetRequestNeeded()
title, hasTitle := req.GetTitle()
edited, err := r.deps.Channels.EditExportedInvite(ctx, userID, domain.EditChannelInviteRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
Revoked: req.Revoked,
HasExpireDate: hasExpireDate,
ExpireDate: expireDate,
HasUsageLimit: hasUsageLimit,
UsageLimit: usageLimit,
HasRequestNeeded: hasRequestNeeded,
RequestNeeded: requestNeeded,
HasTitle: hasTitle,
Title: title,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(view.Channel.ID)
users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID})
if edited.NewInvite != nil {
return &tg.MessagesExportedChatInviteReplaced{
Invite: tgExportedChannelInvite(edited.Invite),
NewInvite: tgExportedChannelInvite(*edited.NewInvite),
Users: users,
}, nil
}
return &tg.MessagesExportedChatInvite{Invite: tgExportedChannelInvite(edited.Invite), Users: users}, nil
}
func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return false, err
}
adminID := userID
if !inputUserIsEmpty(req.AdminID) {
admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID})
if err != nil {
return false, err
}
if len(admins) > 0 {
adminID = admins[0]
}
}
if err := r.deps.Channels.DeleteRevokedExportedInvites(ctx, userID, domain.DeleteRevokedChannelInvitesRequest{
UserID: userID,
ChannelID: view.Channel.ID,
AdminUserID: adminID,
Limit: domain.MaxChannelHideJoinRequests,
}); err != nil {
return false, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(view.Channel.ID)
return true, nil
}
func (r *Router) onMessagesDeleteExportedChatInvite(ctx context.Context, req *tg.MessagesDeleteExportedChatInviteRequest) (bool, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return false, err
}
hash, err := channelInviteHashFromLink(req.Link)
if err != nil {
return false, err
}
if err := r.deps.Channels.DeleteExportedInvite(ctx, userID, domain.DeleteChannelInviteRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
}); err != nil {
return false, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(view.Channel.ID)
return true, nil
}
func (r *Router) onMessagesGetChatInviteImporters(ctx context.Context, req *tg.MessagesGetChatInviteImportersRequest) (*tg.MessagesChatInviteImporters, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
link, hasLink := req.GetLink()
query, hasQuery := req.GetQ()
if hasLink && hasQuery && strings.TrimSpace(link) != "" && strings.TrimSpace(query) != "" {
return nil, tgerr400("SEARCH_WITH_LINK_NOT_SUPPORTED")
}
if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(link) > maxChatInviteLinkLength || len(query) > maxChatInviteSearchLength {
return nil, limitInvalidErr()
}
hash := ""
if strings.TrimSpace(link) != "" {
hash, err = channelInviteHashFromLink(link)
if err != nil {
return nil, err
}
}
offsetUserID := int64(0)
if !inputUserIsEmpty(req.OffsetUser) {
ids, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.OffsetUser})
if err != nil {
return nil, err
}
if len(ids) > 0 {
offsetUserID = ids[0]
}
}
list, err := r.deps.Channels.ListInviteImporters(ctx, userID, domain.ChannelInviteImportersRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
Requested: req.Requested,
Query: query,
OffsetDate: req.OffsetDate,
OffsetUserID: offsetUserID,
Limit: req.Limit,
})
if err != nil {
return nil, channelInviteErr(err)
}
importers := make([]tg.ChatInviteImporter, 0, len(list.Importers))
userIDs := make([]int64, 0, len(list.Importers))
for _, importer := range list.Importers {
tgImporter := tg.ChatInviteImporter{
UserID: importer.UserID,
Date: importer.Date,
}
if importer.Requested {
tgImporter.SetRequested(true)
}
if importer.ApprovedBy != 0 {
tgImporter.SetApprovedBy(importer.ApprovedBy)
userIDs = append(userIDs, importer.ApprovedBy)
}
importers = append(importers, tgImporter)
userIDs = append(userIDs, importer.UserID)
}
return &tg.MessagesChatInviteImporters{
Count: list.Count,
Importers: importers,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
}, nil
}
func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 {
if len(ids) == 0 {
return nil
}
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 || id == selfUserID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass {
out := &tg.ChatInviteExported{
Revoked: invite.Revoked,
Permanent: invite.Permanent,
RequestNeeded: invite.RequestNeeded,
Link: "https://telesrv.net/+" + invite.Hash,
AdminID: invite.AdminUserID,
Date: invite.Date,
}
if invite.Title != "" {
out.SetTitle(invite.Title)
}
if invite.ExpireDate > 0 {
out.SetExpireDate(invite.ExpireDate)
}
if invite.UsageLimit > 0 {
out.SetUsageLimit(invite.UsageLimit)
}
if invite.UsageCount > 0 {
out.SetUsage(invite.UsageCount)
}
if invite.RequestedCount > 0 {
out.SetRequested(invite.RequestedCount)
}
return out
}
func validateChatInviteLink(link string) error {
link = strings.TrimSpace(link)
if link == "" {
return tgerr400("INVITE_HASH_EMPTY")
}
if len(link) > maxChatInviteLinkLength {
return limitInvalidErr()
}
return nil
}
func channelInviteHashFromLink(link string) (string, error) {
if err := validateChatInviteLink(link); err != nil {
return "", err
}
link = strings.TrimSpace(link)
link = strings.TrimPrefix(link, "tg://join?invite=")
if strings.Contains(link, "://") {
if idx := strings.LastIndex(link, "/+"); idx >= 0 {
link = link[idx+2:]
} else if idx := strings.LastIndex(link, "/joinchat/"); idx >= 0 {
link = link[idx+10:]
} else if idx := strings.LastIndex(link, "/"); idx >= 0 {
link = link[idx+1:]
}
}
link = strings.TrimPrefix(link, "+")
link = strings.TrimSpace(link)
if link == "" {
return "", tgerr400("INVITE_HASH_EMPTY")
}
if len(link) > maxChatInviteLinkLength {
return "", limitInvalidErr()
}
return link, nil
}
func channelInviteErr(err error) error {
switch {
case errors.Is(err, domain.ErrInviteHashEmpty):
return tgerr400("INVITE_HASH_EMPTY")
case errors.Is(err, domain.ErrInviteHashInvalid):
return tgerr400("INVITE_HASH_INVALID")
case errors.Is(err, domain.ErrInviteHashExpired):
return tgerr.New(406, "INVITE_HASH_EXPIRED")
case errors.Is(err, domain.ErrInvitePermanent):
return tgerr400("CHAT_INVITE_PERMANENT")
case errors.Is(err, domain.ErrInviteRevokedMissing):
return tgerr400("INVITE_REVOKED_MISSING")
case errors.Is(err, domain.ErrInviteRequestSent):
return tgerr400("INVITE_REQUEST_SENT")
case errors.Is(err, domain.ErrHideRequesterMissing):
return tgerr400("HIDE_REQUESTER_MISSING")
case errors.Is(err, domain.ErrUsersTooMuch):
return tgerr400("USERS_TOO_MUCH")
case errors.Is(err, domain.ErrUserAlreadyParticipant):
return tgerr400("USER_ALREADY_PARTICIPANT")
case errors.Is(err, domain.ErrUserKicked):
return tgerr400("USER_KICKED")
case errors.Is(err, domain.ErrBotGroupsBlocked):
return tgerr400("BOT_GROUPS_BLOCKED")
default:
return channelInvalidErr(err)
}
}

View file

@ -0,0 +1,856 @@
package rpc
import (
"context"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"strings"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestChannelParticipantsSearchQueryIsBounded(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002111", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002112", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Participants RPC Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
_, err = r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsSearch{Q: strings.Repeat("x", domain.MaxChannelParticipantsQueryLength+1)},
Limit: 20,
})
if err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("get participants long query err = %v, want LIMIT_INVALID", err)
}
}
func TestChannelsGetParticipantsUsesSingleBatchUserLookup(t *testing.T) {
ctx := context.Background()
owner := domain.User{ID: 1, AccessHash: 101, Phone: "15550002131", FirstName: "Owner"}
first := domain.User{ID: 2, AccessHash: 102, Phone: "15550002132", FirstName: "First"}
second := domain.User{ID: 3, AccessHash: 103, Phone: "15550002133", FirstName: "Second"}
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{
owner.ID: owner,
first.ID: first,
second.ID: second,
}}}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: users,
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: first.ID, AccessHash: first.AccessHash},
&tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash},
},
Title: "Participants Batch Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
users.byIDCalls = 0
users.byIDsCalls = 0
users.lastByIDs = nil
got, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 20,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
list := got.(*tg.ChannelsChannelParticipants)
if users.byIDsCalls != 1 || users.byIDCalls != 0 {
t.Fatalf("user lookups byIDs=%d byID=%d, want one ByIDs and no ByID", users.byIDsCalls, users.byIDCalls)
}
if len(users.lastByIDs) != len(list.Participants) {
t.Fatalf("ByIDs ids = %+v, participants=%d", users.lastByIDs, len(list.Participants))
}
seen := make(map[int64]struct{}, len(users.lastByIDs))
for _, id := range users.lastByIDs {
seen[id] = struct{}{}
}
for _, want := range []int64{owner.ID, first.ID, second.ID} {
if _, ok := seen[want]; !ok {
t.Fatalf("ByIDs ids = %+v, missing %d", users.lastByIDs, want)
}
}
if len(list.Users) != 3 {
t.Fatalf("users = %+v, want three projected users", list.Users)
}
}
func TestChannelsGetParticipantsValidatesHashAfterParticipantAccessCheck(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002141", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002142", FirstName: "Member"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Participants Access",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700002100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
counting := &countingChannelsService{Service: channelService}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: counting,
}, zaptest.NewLogger(t), clock.System)
got, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 20,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
if _, ok := got.(*tg.ChannelsChannelParticipants); !ok {
t.Fatalf("participants = %T, want *tg.ChannelsChannelParticipants", got)
}
if counting.resolveChannelCalls != 0 || counting.getChannelCalls != 0 {
t.Fatalf("participant access calls ResolveChannel=%d GetChannel=%d, want no pre-resolve/full get", counting.resolveChannelCalls, counting.getChannelCalls)
}
_, err = r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash + 1},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 20,
})
if err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") {
t.Fatalf("bad access_hash err = %v, want CHANNEL_PRIVATE", err)
}
}
func TestChannelsGetParticipantsHidesAnonymousAdminFromRegularMember(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002161", FirstName: "Owner"})
anonymousAdmin, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002162", FirstName: "Hidden"})
regular, _ := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550002163", FirstName: "Regular"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: anonymousAdmin.ID, AccessHash: anonymousAdmin.AccessHash},
&tg.InputUser{UserID: regular.ID, AccessHash: regular.AccessHash},
},
Title: "Anonymous Admin RPC Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: anonymousAdmin.ID, AccessHash: anonymousAdmin.AccessHash},
AdminRights: tg.ChatAdminRights{
Anonymous: true,
ChangeInfo: true,
},
}); err != nil {
t.Fatalf("edit anonymous admin: %v", err)
}
adminsForRegular, err := r.onChannelsGetParticipants(WithUserID(ctx, regular.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 10,
})
if err != nil {
t.Fatalf("regular get admins: %v", err)
}
regularAdminsPage := adminsForRegular.(*tg.ChannelsChannelParticipants)
if tgParticipantListHasUser(regularAdminsPage.Participants, anonymousAdmin.ID) || tgUserListHasUser(regularAdminsPage.Users, anonymousAdmin.ID) {
t.Fatalf("regular admins page leaks anonymous admin: participants=%+v users=%+v", regularAdminsPage.Participants, regularAdminsPage.Users)
}
if !tgParticipantListHasUser(regularAdminsPage.Participants, owner.ID) {
t.Fatalf("regular admins page = %+v, want creator still visible", regularAdminsPage.Participants)
}
recentForRegular, err := r.onChannelsGetParticipants(WithUserID(ctx, regular.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("regular get recent: %v", err)
}
regularRecentPage := recentForRegular.(*tg.ChannelsChannelParticipants)
if tgParticipantListHasUser(regularRecentPage.Participants, anonymousAdmin.ID) || tgUserListHasUser(regularRecentPage.Users, anonymousAdmin.ID) {
t.Fatalf("regular recent page leaks anonymous admin: participants=%+v users=%+v", regularRecentPage.Participants, regularRecentPage.Users)
}
if regularRecentPage.Count != 2 {
t.Fatalf("regular recent count = %d, want visible member count 2", regularRecentPage.Count)
}
adminsForOwner, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 10,
})
if err != nil {
t.Fatalf("owner get admins: %v", err)
}
if !tgParticipantListHasUser(adminsForOwner.(*tg.ChannelsChannelParticipants).Participants, anonymousAdmin.ID) {
t.Fatalf("owner admins page = %+v, want anonymous admin visible to admins", adminsForOwner.(*tg.ChannelsChannelParticipants).Participants)
}
}
func tgParticipantListHasUser(participants []tg.ChannelParticipantClass, userID int64) bool {
for _, participant := range participants {
for _, id := range channelParticipantUserRefs(participant) {
if id == userID {
return true
}
}
}
return false
}
func tgUserListHasUser(users []tg.UserClass, userID int64) bool {
for _, user := range users {
if u, ok := user.(*tg.User); ok && u.ID == userID {
return true
}
}
return false
}
// TestChannelCreateHasPermanentInviteLink 复刻 DrKLO 建频道后的真实调用序列:
// ChannelCreateActivity.generateLink() 发 getExportedChatInvites(admin=self, limit=1)
// 后对 invites.get(0) 直接取值,空列表即 IndexOutOfBounds 闪退——服务端必须保证
// 创建者的永久主链接随创建即存在,且重复列出不会重复生成。
func TestChannelCreateHasPermanentInviteLink(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Broadcast: true,
Title: "Crash Repro Channel",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, owner.ID), &tg.MessagesGetExportedChatInvitesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
AdminID: &tg.InputUserSelf{},
Limit: 1,
})
if err != nil {
t.Fatalf("get exported invites after create: %v", err)
}
if inviteList.Count < 1 || len(inviteList.Invites) < 1 {
t.Fatalf("exported invites after create = %+v, want at least the permanent link (DrKLO 直接取 invites[0])", inviteList)
}
invite, ok := inviteList.Invites[0].(*tg.ChatInviteExported)
if !ok || !invite.Permanent || invite.Revoked || invite.AdminID != owner.ID || !strings.HasPrefix(invite.Link, "https://telesrv.net/+") {
t.Fatalf("invites[0] = %#v, want creator's non-revoked permanent link", inviteList.Invites[0])
}
// 幂等:再次列出仍只有同一条主链接,不重复生成。
again, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, owner.ID), &tg.MessagesGetExportedChatInvitesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
AdminID: &tg.InputUserSelf{},
Limit: 10,
})
if err != nil {
t.Fatalf("get exported invites again: %v", err)
}
if again.Count != 1 || len(again.Invites) != 1 {
t.Fatalf("second list = %+v, want exactly one permanent link", again)
}
if link := again.Invites[0].(*tg.ChatInviteExported).Link; link != invite.Link {
t.Fatalf("second list link = %q, want stable %q", link, invite.Link)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel: %v", err)
}
fullInviteRaw, ok := full.FullChat.(*tg.ChannelFull).GetExportedInvite()
if !ok {
t.Fatalf("channelFull.exported_invite missing, want creator permanent link for DrKLO group settings")
}
fullInvite := fullInviteRaw.(*tg.ChatInviteExported)
if fullInvite.Link != invite.Link || !fullInvite.Permanent || fullInvite.Revoked {
t.Fatalf("channelFull.exported_invite = %#v, want active permanent link %q", fullInvite, invite.Link)
}
replacedRaw, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
LegacyRevokePermanent: true,
})
if err != nil {
t.Fatalf("replace permanent invite: %v", err)
}
replaced := replacedRaw.(*tg.ChatInviteExported)
if replaced.Link == invite.Link || !replaced.Permanent || replaced.Revoked {
t.Fatalf("replaced permanent invite = %#v, want a fresh active permanent link", replaced)
}
refreshedFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after replacing invite: %v", err)
}
refreshedRaw, ok := refreshedFull.FullChat.(*tg.ChannelFull).GetExportedInvite()
if !ok {
t.Fatalf("channelFull.exported_invite missing after replacing permanent link")
}
refreshedInvite := refreshedRaw.(*tg.ChatInviteExported)
if refreshedInvite.Link != replaced.Link || !refreshedInvite.Permanent || refreshedInvite.Revoked {
t.Fatalf("channelFull.exported_invite after replace = %#v, want fresh active permanent link %q", refreshedInvite, replaced.Link)
}
}
func TestChannelAdminPinInviteRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002201", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002202", FirstName: "Friend"})
joiner, _ := userStore.Create(ctx, domain.User{AccessHash: 53, Phone: "15550002203", FirstName: "Joiner"})
invited, _ := userStore.Create(ctx, domain.User{AccessHash: 54, Phone: "15550002204", FirstName: "Invited"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "RPC Admin Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
createdChannel, err := channelStore.GetChannelByID(ctx, channel.ID)
if err != nil {
t.Fatalf("get created channel: %v", err)
}
initialChannelPts := createdChannel.Pts
selfParticipant, err := r.onChannelsGetParticipant(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Participant: &tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("get self regular participant: %v", err)
}
if _, ok := selfParticipant.Participant.(*tg.ChannelParticipantSelf); !ok {
t.Fatalf("self regular participant = %T, want channelParticipantSelf", selfParticipant.Participant)
}
recentForFriend, err := r.onChannelsGetParticipants(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("get recent participants for regular self: %v", err)
}
foundSelf := false
for _, participant := range recentForFriend.(*tg.ChannelsChannelParticipants).Participants {
if _, ok := participant.(*tg.ChannelParticipantSelf); ok {
foundSelf = true
break
}
}
if !foundSelf {
t.Fatalf("recent participants = %+v, want current regular member as channelParticipantSelf", recentForFriend.(*tg.ChannelsChannelParticipants).Participants)
}
adminUpdates, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
AdminRights: tg.ChatAdminRights{
ChangeInfo: true,
InviteUsers: true,
PinMessages: true,
},
Rank: "ops",
})
if err != nil {
t.Fatalf("edit admin: %v", err)
}
if updates := adminUpdates.(*tg.Updates); len(updates.Updates) != 2 {
t.Fatalf("admin updates = %+v, want participant update and channel refresh", updates.Updates)
} else if _, ok := updates.Updates[0].(*tg.UpdateChannelParticipant); !ok {
t.Fatalf("admin update[0] = %T, want updateChannelParticipant", updates.Updates[0])
} else if _, ok := updates.Updates[1].(*tg.UpdateChannel); !ok {
t.Fatalf("admin update[1] = %T, want updateChannel", updates.Updates[1])
}
adminDiff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: initialChannelPts,
Limit: 10,
})
if err != nil {
t.Fatalf("channel difference after admin: %v", err)
}
adminEmptyDiff, ok := adminDiff.(*tg.UpdatesChannelDifferenceEmpty)
if !ok || !adminEmptyDiff.Final || adminEmptyDiff.Pts != initialChannelPts {
t.Fatalf("admin diff = %T %+v, want empty difference at unchanged pts %d", adminDiff, adminDiff, initialChannelPts)
}
admins, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 10,
})
if err != nil {
t.Fatalf("get admin participants: %v", err)
}
if list := admins.(*tg.ChannelsChannelParticipants); len(list.Participants) != 2 {
t.Fatalf("admin participants = %+v, want creator and promoted admin", list.Participants)
}
titleUpdates, err := r.onChannelsEditTitle(WithUserID(ctx, friend.ID), &tg.ChannelsEditTitleRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Title: "RPC Admin Group 2",
})
if err != nil {
t.Fatalf("edit title: %v", err)
}
titleContainer := titleUpdates.(*tg.Updates)
if len(titleContainer.Updates) < 2 {
t.Fatalf("title updates = %+v, want channel + service message", titleContainer.Updates)
}
titleMsg, ok := titleContainer.Updates[1].(*tg.UpdateNewChannelMessage)
if !ok {
t.Fatalf("title update[1] = %T, want updateNewChannelMessage", titleContainer.Updates[1])
}
if action := titleMsg.Message.(*tg.MessageService).Action; action.(*tg.MessageActionChatEditTitle).Title != "RPC Admin Group 2" {
t.Fatalf("title action = %#v, want new title", action)
}
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "pin me",
RandomID: 123,
})
if err != nil {
t.Fatalf("send for pin: %v", err)
}
msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
pinUpdates, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, friend.ID), &tg.MessagesUpdatePinnedMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: msgID,
})
if err != nil {
t.Fatalf("pin message: %v", err)
}
pinned, ok := pinUpdates.(*tg.Updates).Updates[0].(*tg.UpdatePinnedChannelMessages)
if !ok || !pinned.Pinned || pinned.Messages[0] != msgID {
t.Fatalf("pin update = %#v, want pinned channel message id=%d", pinUpdates.(*tg.Updates).Updates[0], msgID)
}
invitedUsers, err := r.onChannelsInviteToChannel(WithUserID(ctx, friend.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}},
})
if err != nil {
t.Fatalf("invite to channel: %v", err)
}
if invitedUsers.Updates == nil || len(invitedUsers.MissingInvitees) != 0 {
t.Fatalf("invited users = %+v, want updates and no missing users", invitedUsers)
}
inviteUpdates, ok := invitedUsers.Updates.(*tg.Updates)
if !ok || len(inviteUpdates.Chats) == 0 {
t.Fatalf("invite updates = %T %+v, want channel chat", invitedUsers.Updates, invitedUsers.Updates)
}
assertDefaultBannedRightsAllowsSend(t, inviteUpdates.Chats[0])
invite, err := r.onMessagesExportChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesExportChatInviteRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Title: "join",
})
if err != nil {
t.Fatalf("export invite: %v", err)
}
exported := invite.(*tg.ChatInviteExported)
hash := strings.TrimPrefix(exported.Link, "https://telesrv.net/+")
checked, err := r.onMessagesCheckChatInvite(WithUserID(ctx, joiner.ID), hash)
if err != nil {
t.Fatalf("check invite: %v", err)
}
if preview, ok := checked.(*tg.ChatInvite); !ok || !preview.Megagroup || preview.Title != "RPC Admin Group 2" {
t.Fatalf("invite preview = %#v, want megagroup title", checked)
}
imported, err := r.onMessagesImportChatInvite(WithUserID(ctx, joiner.ID), hash)
if err != nil {
t.Fatalf("import invite: %v", err)
}
importOk, ok := imported.(*tg.MessagesChatInviteJoinResultOk)
if !ok {
t.Fatalf("import result = %T, want *tg.MessagesChatInviteJoinResultOk", imported)
}
importUpdates := importOk.Updates.(*tg.Updates)
if len(importUpdates.Chats) != 1 || len(importUpdates.Updates) != 2 {
t.Fatalf("import updates = %+v, want chat, join service update, and channel refresh", importUpdates)
}
assertDefaultBannedRightsAllowsSend(t, importUpdates.Chats[0])
if _, ok := importUpdates.Updates[0].(*tg.UpdateNewChannelMessage); !ok {
t.Fatalf("import first update = %T, want join service update", importUpdates.Updates[0])
} else if refresh, ok := importUpdates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
t.Fatalf("import second update = %#v, want channel refresh", importUpdates.Updates[1])
}
inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
AdminID: &tg.InputUserSelf{},
Limit: 10,
})
if err != nil {
t.Fatalf("get exported invites: %v", err)
}
// 官方语义:管理员列出自己的有效链接时永久主链接必有(首页自愈生成),
// 因此列表 = 主链接 + 显式导出的 "join" 链接。
if inviteList.Count != 2 || len(inviteList.Invites) != 2 || len(inviteList.Users) == 0 {
t.Fatalf("exported invite list = %+v, want permanent link plus exported invite", inviteList)
}
var listedInvite, permanentInvite *tg.ChatInviteExported
for _, raw := range inviteList.Invites {
invite, ok := raw.(*tg.ChatInviteExported)
if !ok {
t.Fatalf("invite = %T, want *tg.ChatInviteExported", raw)
}
if invite.Permanent {
permanentInvite = invite
}
if invite.Link == exported.Link {
listedInvite = invite
}
}
if permanentInvite == nil || permanentInvite.Revoked || permanentInvite.AdminID != friend.ID {
t.Fatalf("exported invite list = %+v, want non-revoked permanent link owned by admin", inviteList.Invites)
}
if listedInvite == nil {
t.Fatalf("exported invite list = %+v, missing explicitly exported link %q", inviteList.Invites, exported.Link)
}
listedUsage, listedUsageOK := listedInvite.GetUsage()
listedTitle, listedTitleOK := listedInvite.GetTitle()
if !listedUsageOK || listedUsage != 1 || !listedTitleOK || listedTitle != "join" {
t.Fatalf("listed invite = %#v, want exported link with one import", listedInvite)
}
if _, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
AdminID: &tg.InputUserSelf{},
Limit: 101,
}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("get exported invites high limit err = %v, want LIMIT_INVALID", err)
}
inviteDetails, err := r.onMessagesGetExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInviteRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Link: exported.Link,
})
if err != nil {
t.Fatalf("get exported invite: %v", err)
}
if details := inviteDetails.(*tg.MessagesExportedChatInvite); details.Invite == nil || len(details.Users) == 0 {
t.Fatalf("exported invite details = %+v, want invite plus user context", inviteDetails)
}
editInviteReq := &tg.MessagesEditExportedChatInviteRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Link: exported.Link,
}
editInviteReq.SetTitle("ops link")
editedInvite, err := r.onMessagesEditExportedChatInvite(WithUserID(ctx, friend.ID), editInviteReq)
if err != nil {
t.Fatalf("edit exported invite: %v", err)
}
if edited := editedInvite.(*tg.MessagesExportedChatInvite); edited.Invite == nil || len(edited.Users) == 0 {
t.Fatalf("edited invite = %+v, want invite plus user context", editedInvite)
} else if got, ok := edited.Invite.(*tg.ChatInviteExported).GetTitle(); !ok || got != "ops link" {
t.Fatalf("edited invite title = %q, want ops link", got)
}
adminsWithInvites, err := r.onMessagesGetAdminsWithInvites(WithUserID(ctx, friend.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get admins with invites: %v", err)
}
// 创建者随建自动持有主链接1 条friend 为显式导出的 "join" + 列表自愈
// 生成的主链接2 条)。
adminInvites := map[int64]int{}
for _, admin := range adminsWithInvites.Admins {
adminInvites[admin.AdminID] = admin.InvitesCount
}
if len(adminsWithInvites.Admins) != 2 || adminInvites[owner.ID] != 1 || adminInvites[friend.ID] != 2 || len(adminsWithInvites.Users) == 0 {
t.Fatalf("admins with invites = %+v, want creator permanent link plus friend's two links", adminsWithInvites)
}
importers, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), &tg.MessagesGetChatInviteImportersRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 10,
})
if err != nil {
t.Fatalf("get invite importers: %v", err)
}
if importers.Count != 1 || len(importers.Importers) != 1 || importers.Importers[0].UserID != joiner.ID || len(importers.Users) == 0 {
t.Fatalf("invite importers = %+v, want joined importer", importers)
}
importersSearchReq := &tg.MessagesGetChatInviteImportersRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 10,
}
importersSearchReq.SetLink(exported.Link)
importersSearchReq.SetQ("bob")
if _, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), importersSearchReq); err == nil || !strings.Contains(err.Error(), "SEARCH_WITH_LINK_NOT_SUPPORTED") {
t.Fatalf("get invite importers q+link err = %v, want SEARCH_WITH_LINK_NOT_SUPPORTED", err)
}
if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, friend.ID), &tg.MessagesHideChatJoinRequestRequest{
Approved: true,
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash},
}); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") {
t.Fatalf("hide chat join request without pending err = %v, want HIDE_REQUESTER_MISSING", err)
}
if updates, err := r.onMessagesHideAllChatJoinRequests(WithUserID(ctx, friend.ID), &tg.MessagesHideAllChatJoinRequestsRequest{
Approved: false,
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
}); err != nil {
t.Fatalf("hide all chat join requests: %v", err)
} else if _, ok := updates.(*tg.Updates); !ok {
t.Fatalf("hide all chat join requests updates = %T, want *tg.Updates", updates)
}
if ok, err := r.onMessagesDeleteExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesDeleteExportedChatInviteRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Link: exported.Link,
}); err != nil || !ok {
t.Fatalf("delete exported invite ok=%v err=%v, want true nil", ok, err)
}
if ok, err := r.onMessagesDeleteRevokedExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesDeleteRevokedExportedChatInvitesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
AdminID: &tg.InputUserSelf{},
}); err != nil || !ok {
t.Fatalf("delete revoked invites ok=%v err=%v, want true nil", ok, err)
}
adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 20,
})
if err != nil {
t.Fatalf("get admin log: %v", err)
}
if len(adminLog.Events) < 5 || len(adminLog.Chats) != 1 || len(adminLog.Users) < 3 {
t.Fatalf("admin log = %+v, want events plus chat/users", adminLog)
}
tooManyAdmins := make([]tg.InputUserClass, domain.MaxChannelAdminLogAdmins+1)
for i := range tooManyAdmins {
tooManyAdmins[i] = &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash}
}
tooManyAdminsReq := &tg.ChannelsGetAdminLogRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 1,
}
tooManyAdminsReq.SetAdmins(tooManyAdmins)
if _, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), tooManyAdminsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("get admin log too many admins err = %v, want LIMIT_INVALID", err)
}
pinnedFilter := tg.ChannelAdminLogEventsFilter{}
pinnedFilter.SetPinned(true)
pinnedReq := &tg.ChannelsGetAdminLogRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 10,
}
pinnedReq.SetEventsFilter(pinnedFilter)
pinnedLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), pinnedReq)
if err != nil {
t.Fatalf("get pinned admin log: %v", err)
}
if len(pinnedLog.Events) != 1 {
t.Fatalf("pinned admin log events = %+v, want one", pinnedLog.Events)
}
if _, ok := pinnedLog.Events[0].Action.(*tg.ChannelAdminLogEventActionUpdatePinned); !ok {
t.Fatalf("pinned admin log action = %T, want updatePinned", pinnedLog.Events[0].Action)
}
unpinnedAll, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err != nil {
t.Fatalf("unpin all messages: %v", err)
}
if unpinnedAll.Pts == 0 || unpinnedAll.PtsCount != 1 || unpinnedAll.Offset != 0 {
t.Fatalf("unpin all affected history = %+v, want one channel pts event", unpinnedAll)
}
afterUnpin, err := r.deps.Channels.GetChannel(ctx, friend.ID, channel.ID)
if err != nil {
t.Fatalf("get channel after unpin: %v", err)
}
if afterUnpin.Channel.PinnedMessageID != 0 {
t.Fatalf("pinned message after unpin all = %d, want 0", afterUnpin.Channel.PinnedMessageID)
}
unpinnedAgain, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err != nil {
t.Fatalf("unpin all messages again: %v", err)
}
if unpinnedAgain.Pts != afterUnpin.Channel.Pts || unpinnedAgain.PtsCount != 0 || unpinnedAgain.Offset != 0 {
t.Fatalf("unpin all no-op affected history = %+v, want current pts with zero pts_count", unpinnedAgain)
}
invalidTopicUnpin := &tg.MessagesUnpinAllMessagesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
}
invalidTopicUnpin.SetTopMsgID(domain.MaxMessageBoxID + 1)
if _, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), invalidTopicUnpin); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("unpin all invalid top msg err = %v, want MESSAGE_ID_INVALID", err)
}
}
func TestChannelEditBannedKickNotifiesKickedViewer(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002258", FirstName: "Owner"})
kicked, _ := userStore.Create(ctx, domain.User{AccessHash: 59, Phone: "15550002259", FirstName: "Kicked"})
channelStore := memory.NewChannelStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}},
Title: "Kick Notify",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
// 被踢者视角的推送 chats 必须是完整(非 min投影并带 left
// 客户端只对非 min channel 应用 left/banned_rights。
kickedView := r.channelParticipantUpdates(ctx, kicked.ID, owner.ID, domain.Channel{ID: channel.ID, AccessHash: channel.AccessHash, Title: channel.Title, Megagroup: true},
domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberActive},
domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberKicked, BannedRights: domain.ChannelBannedRights{ViewMessages: true}},
1700000000)
if len(kickedView.Chats) != 1 {
t.Fatalf("kicked view chats = %+v, want one chat", kickedView.Chats)
}
kickedChat, ok := kickedView.Chats[0].(*tg.Channel)
if !ok {
t.Fatalf("kicked chat = %#v, want *tg.Channel", kickedView.Chats[0])
}
if kickedChat.Min {
t.Fatalf("kicked chat = %#v, must not be min: min objects do not apply membership state", kickedChat)
}
if _, hasBanned := kickedChat.GetBannedRights(); !hasBanned {
t.Fatalf("kicked chat = %#v, want banned_rights so the viewer learns the kick", kickedChat)
}
adminView := r.channelParticipantUpdates(ctx, owner.ID, owner.ID, domain.Channel{ID: channel.ID, AccessHash: channel.AccessHash, Title: channel.Title, Megagroup: true},
domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberActive},
domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberKicked},
1700000000)
if adminChat, ok := adminView.Chats[0].(*tg.Channel); !ok || !adminChat.Min {
t.Fatalf("admin-side chat = %#v, want min channel that preserves local rights", adminView.Chats[0])
}
if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{
Channel: input,
Participant: &tg.InputPeerUser{UserID: kicked.ID, AccessHash: kicked.AccessHash},
BannedRights: tg.ChatBannedRights{ViewMessages: true},
}); err != nil {
t.Fatalf("kick member: %v", err)
}
// 被踢后 channels.getChannels 必须返回 channelForbidden 而不是省略。
got, err := r.onChannelsGetChannels(WithUserID(ctx, kicked.ID), []tg.InputChannelClass{input})
if err != nil {
t.Fatalf("kicked getChannels: %v", err)
}
chats, ok := got.(*tg.MessagesChats)
if !ok || len(chats.Chats) != 1 {
t.Fatalf("kicked getChannels = %T %+v, want one channelForbidden", got, got)
}
forbidden, ok := chats.Chats[0].(*tg.ChannelForbidden)
if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || !forbidden.Megagroup {
t.Fatalf("kicked chat = %#v, want channelForbidden tombstone", chats.Chats[0])
}
}
func TestChannelInviteKickedMemberRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 55, Phone: "15550002255", FirstName: "Owner"})
helper, _ := userStore.Create(ctx, domain.User{AccessHash: 56, Phone: "15550002256", FirstName: "Helper"})
kicked, _ := userStore.Create(ctx, domain.User{AccessHash: 57, Phone: "15550002257", FirstName: "Kicked"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: helper.ID, AccessHash: helper.AccessHash},
&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash},
},
Title: "RPC Invite Kicked",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{
Channel: input,
Participant: &tg.InputPeerUser{UserID: kicked.ID, AccessHash: kicked.AccessHash},
BannedRights: tg.ChatBannedRights{
ViewMessages: true,
},
}); err != nil {
t.Fatalf("kick member: %v", err)
}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, helper.ID), &tg.ChannelsInviteToChannelRequest{
Channel: input,
Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}},
}); err == nil || !strings.Contains(err.Error(), "USER_KICKED") {
t.Fatalf("helper invite kicked err = %v, want USER_KICKED", err)
}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: input,
Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}},
}); err != nil {
t.Fatalf("owner restore kicked invite: %v", err)
}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: input,
Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}},
}); err == nil || !strings.Contains(err.Error(), "USER_ALREADY_PARTICIPANT") {
t.Fatalf("duplicate invite err = %v, want USER_ALREADY_PARTICIPANT", err)
}
}

View file

@ -0,0 +1,67 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestBroadcastSelfJoinParticipantCarriesSelfInviter pins the fix for "joined a broadcast channel but
// no dialog appears". A broadcast join emits no service message; the official client materializes the
// chat-list entry by generating a LOCAL "you joined this channel" service message, which it only does
// when channel->inviter is a valid user — set from channels.getParticipant(self).inviter_id. Real TG
// returns inviter_id == user_id (self) for a self-join. telesrv previously left InviterUserID==0, so
// the client never generated the joined message and the channel stayed out of the list.
func TestBroadcastSelfJoinParticipantCarriesSelfInviter(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner"})
joiner, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Joiner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Self Join Broadcast",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, joiner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}); err != nil {
t.Fatalf("self join broadcast: %v", err)
}
res, err := r.onChannelsGetParticipant(WithUserID(ctx, joiner.ID), &tg.ChannelsGetParticipantRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Participant: &tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("get participant self: %v", err)
}
self, ok := res.Participant.(*tg.ChannelParticipantSelf)
if !ok {
t.Fatalf("self participant = %T, want *tg.ChannelParticipantSelf", res.Participant)
}
if self.UserID != joiner.ID {
t.Fatalf("participant user_id = %d, want %d", self.UserID, joiner.ID)
}
if self.InviterID != joiner.ID {
t.Fatalf("participant inviter_id = %d, want self %d (so the client generates the joined message)", self.InviterID, joiner.ID)
}
if self.Date == 0 {
t.Fatalf("participant date = 0, want the join date")
}
}

View file

@ -0,0 +1,132 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
admin, err := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Admin"})
if err != nil {
t.Fatalf("create admin: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 9103, Phone: "15550009103", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009100, 0)})
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "leave owner transfer",
Megagroup: true,
MemberUserIDs: []int64{admin.ID, member.ID},
Date: 1700009100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := channelService.EditAdmin(ctx, owner.ID, domain.EditChannelAdminRequest{
UserID: owner.ID,
ChannelID: created.Channel.ID,
MemberID: admin.ID,
AdminRights: domain.ChannelAdminRights{
ChangeInfo: true,
AddAdmins: true,
},
Date: 1700009101,
}); err != nil {
t.Fatalf("promote admin: %v", err)
}
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
inputChannel := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
future, err := r.onMessagesGetFutureChatCreatorAfterLeave(WithUserID(ctx, owner.ID), peer)
if err != nil {
t.Fatalf("get future creator: %v", err)
}
if user, ok := future.(*tg.User); !ok || user.ID != admin.ID {
t.Fatalf("future creator = %T %+v, want admin user %d", future, future, admin.ID)
}
if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil {
t.Fatalf("creator leaves: %v", err)
}
view, err := channelService.GetChannel(ctx, admin.ID, created.Channel.ID)
if err != nil {
t.Fatalf("get channel after leave: %v", err)
}
if view.Channel.CreatorUserID != admin.ID || view.Self.Role != domain.ChannelRoleCreator {
t.Fatalf("channel after leave = %+v self=%+v, want admin as creator", view.Channel, view.Self)
}
oldOwner, err := channelService.GetParticipant(ctx, admin.ID, created.Channel.ID, owner.ID)
if err != nil {
t.Fatalf("get old owner after leave: %v", err)
}
if oldOwner.Status != domain.ChannelMemberLeft || oldOwner.Role == domain.ChannelRoleCreator {
t.Fatalf("old owner after leave = %+v, want left non-creator", oldOwner)
}
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil {
t.Fatalf("old owner rejoins: %v", err)
}
rejoined, err := channelService.GetParticipant(ctx, admin.ID, created.Channel.ID, owner.ID)
if err != nil {
t.Fatalf("get old owner after rejoin: %v", err)
}
if rejoined.Role != domain.ChannelRoleMember || rejoined.Status != domain.ChannelMemberActive || rejoined.Rank != "" {
t.Fatalf("rejoined old owner = %+v, want active plain member", rejoined)
}
}
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9111, Phone: "15550009111", FirstName: "Solo"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009120, 0)})
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "solo owner",
Megagroup: true,
Date: 1700009120,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
inputChannel := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
if _, err := r.onMessagesGetFutureChatCreatorAfterLeave(WithUserID(ctx, owner.ID), peer); err == nil || !tgerr.Is(err, "USER_NOT_PARTICIPANT") {
t.Fatalf("future creator err = %v, want USER_NOT_PARTICIPANT", err)
}
if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, owner.ID), inputChannel); err == nil || !tgerr.Is(err, "USER_CREATOR") {
t.Fatalf("creator leave err = %v, want USER_CREATOR", err)
}
}

View file

@ -0,0 +1,601 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
"unicode/utf8"
)
func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreateChatRequest) (*tg.MessagesInvitedUsers, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if !validChannelTitle(req.Title) || len(req.Users) > domain.MaxChannelInviteUsers {
return nil, channelInvalidErr(domain.ErrChannelTitleInvalid)
}
if req.TTLPeriod < 0 {
return nil, ttlPeriodInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
memberIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users)
if err != nil {
return nil, err
}
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
date := int(r.clock.Now().Unix())
r.log.Debug("messages.createChat resolved users",
zap.Int("input_users", len(req.Users)),
zap.Int("member_ids", len(memberIDs)),
zap.Int64s("member_user_ids", memberIDs),
)
if len(memberIDs) == 0 {
return nil, usersTooFewErr()
}
createRes, err := r.deps.Channels.CreateMegagroupFromCreateChat(ctx, userID, domain.CreateChannelRequest{
CreatorUserID: userID,
Title: req.Title,
TTLPeriod: req.TTLPeriod,
Date: date,
})
if err != nil {
return nil, channelInvalidErr(err)
}
r.addOnlineChannelMemberships(createRes.Channel.ID, channelMemberUserIDs(createRes.Members)...)
responseRes := createRes
var inviteRes domain.CreateChannelResult
if len(memberIDs) > 0 {
inviteRes, err = r.deps.Channels.InviteToChannel(ctx, userID, createRes.Channel.ID, memberIDs, date)
if err != nil {
return nil, channelInviteErr(err)
}
r.addOnlineChannelMemberships(inviteRes.Channel.ID, channelMemberUserIDs(inviteRes.Members)...)
responseRes.Channel = inviteRes.Channel
responseRes.Members = mergeChannelMembers(createRes.Members, inviteRes.Members)
responseRes.Recipients = uniqueRecipientIDs(append(append([]int64{}, createRes.Recipients...), inviteRes.Recipients...))
}
cache := newViewerPeerCache(r)
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, responseRes, cache)
if createChatNeedsLegacyChat(ctx) {
updates = r.tdesktopCreateChatUpdatesWithPeerCache(ctx, userID, responseRes, cache)
}
if inviteRes.Event.Pts != 0 {
inviteUpdates := r.channelOperationUpdatesWithPeerCache(ctx, userID, inviteRes, cache)
if inviteUpdates != nil {
updates.Updates = append(updates.Updates, inviteUpdates.Updates...)
}
}
if inviteRes.Event.Pts != 0 {
r.pushChannelExplicitUpdates(ctx, userID, inviteRes.Channel.ID, memberIDs, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
})
}
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
}
func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if chatID <= 0 {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
userID, view, err := r.channelChangeInfoView(ctx, &tg.InputChannel{ChannelID: chatID})
if err != nil {
return nil, err
}
if !view.Channel.Megagroup {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
return r.channelStateUpdates(userID, view.Channel), nil
}
func (r *Router) onMessagesGetChats(ctx context.Context, ids []int64) (tg.MessagesChatsClass, error) {
if len(ids) > maxGetMessagesIDs {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
chats := make([]tg.ChatClass, 0, len(ids))
if r.deps.Channels != nil {
unique := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
views, err := r.deps.Channels.GetChannels(ctx, userID, unique)
if err != nil {
return nil, channelInvalidErr(err)
}
byID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
if view.Channel.ID != 0 {
byID[view.Channel.ID] = view
}
}
for _, id := range ids {
if id <= 0 {
continue
}
if view, ok := byID[id]; ok {
chats = append(chats, tgChannelChatForView(userID, view))
}
}
}
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onMessagesGetFullChat(ctx context.Context, chatID int64) (*tg.MessagesChatFull, error) {
if chatID <= 0 {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
return r.onChannelsGetFullChannel(ctx, &tg.InputChannel{ChannelID: chatID})
}
func (r *Router) onMessagesAddChatUser(ctx context.Context, req *tg.MessagesAddChatUserRequest) (*tg.MessagesInvitedUsers, error) {
if req.ChatID <= 0 || req.UserID == nil {
return nil, peerIDInvalidErr()
}
return r.onChannelsInviteToChannel(ctx, &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: req.ChatID},
Users: []tg.InputUserClass{req.UserID},
})
}
func (r *Router) onMessagesDeleteChatUser(ctx context.Context, req *tg.MessagesDeleteChatUserRequest) (tg.UpdatesClass, error) {
if req.ChatID <= 0 || req.UserID == nil {
return nil, peerIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found || target.ID == 0 {
return nil, peerIDInvalidErr()
}
if target.ID == userID {
return r.onChannelsLeaveChannel(ctx, &tg.InputChannel{ChannelID: req.ChatID})
}
return r.onChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{
Channel: &tg.InputChannel{ChannelID: req.ChatID},
Participant: &tg.InputPeerUser{UserID: target.ID, AccessHash: target.AccessHash},
BannedRights: tg.ChatBannedRights{
ViewMessages: true,
UntilDate: 0,
},
})
}
func (r *Router) onMessagesEditChatTitle(ctx context.Context, req *tg.MessagesEditChatTitleRequest) (tg.UpdatesClass, error) {
if req.ChatID <= 0 {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
return r.onChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{
Channel: &tg.InputChannel{ChannelID: req.ChatID},
Title: req.Title,
})
}
func (r *Router) onMessagesEditChatPhoto(ctx context.Context, req *tg.MessagesEditChatPhotoRequest) (tg.UpdatesClass, error) {
if req.ChatID <= 0 || req.Photo == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
return r.onChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{
Channel: &tg.InputChannel{ChannelID: req.ChatID},
Photo: req.Photo,
})
}
func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEditChatAdminRequest) (bool, error) {
if req.ChatID <= 0 || req.UserID == nil {
return false, peerIDInvalidErr()
}
rights := tg.ChatAdminRights{}
if req.IsAdmin {
rights = legacyBasicGroupAdminRights()
}
_, err := r.onChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: req.ChatID},
UserID: req.UserID,
AdminRights: rights,
})
if err != nil {
return false, err
}
return true, nil
}
func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) {
if r.deps.Channels == nil {
return false, notImplementedErr()
}
if utf8.RuneCountInString(req.About) > maxChannelAboutLength {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
if err != nil {
return false, err
}
channel, err := r.deps.Channels.EditAbout(ctx, userID, domain.EditChannelAboutRequest{
UserID: userID,
ChannelID: channelID,
About: req.About,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return false, channelAdminErr(err)
}
r.pushChannelStateToMembers(ctx, userID, channel)
return true, nil
}
func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
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 || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
channel, err := r.deps.Channels.EditDefaultBannedRights(ctx, userID, domain.EditChannelDefaultBannedRightsRequest{
UserID: userID,
ChannelID: peer.ID,
BannedRights: domainChannelBannedRights(req.BannedRights),
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) {
if req.UserID == nil {
return nil, peerIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil {
return nil, err
}
if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil {
return nil, internalErr()
} else if !found {
return nil, peerIDInvalidErr()
}
return nil, tgerr.New(400, "PASSWORD_HASH_INVALID")
}
func (r *Router) onMessagesGetFutureChatCreatorAfterLeave(ctx context.Context, peer tg.InputPeerClass) (tg.UserClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
resolved, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
if err != nil {
return nil, err
}
if resolved.Type != domain.PeerTypeChannel || resolved.ID == 0 {
return nil, peerIDInvalidErr()
}
member, err := r.deps.Channels.FutureCreatorAfterLeave(ctx, userID, resolved.ID)
if err != nil {
return nil, channelAdminErr(err)
}
if r.deps.Users == nil {
return nil, userIDInvalidErr()
}
user, found, err := r.deps.Users.ByID(ctx, userID, member.UserID)
if err != nil {
return nil, internalErr()
}
if !found || user.Bot {
return nil, tgerr400("USER_NOT_PARTICIPANT")
}
users := r.tgUsersForViewer(userID, []domain.User{user})
if len(users) == 0 {
return nil, userIDInvalidErr()
}
return users[0], nil
}
func (r *Router) onMessagesEditChatParticipantRank(ctx context.Context, req *tg.MessagesEditChatParticipantRankRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if len(req.Rank) > domain.MaxChannelAdminRankLength {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
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
}
participant, ok := r.domainPeerFromInputPeer(userID, req.Participant)
if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 {
return nil, peerIDInvalidErr()
}
res, err := r.deps.Channels.EditMemberRank(ctx, userID, domain.EditChannelMemberRankRequest{
UserID: userID,
ChannelID: channelID,
MemberID: participant.ID,
Rank: req.Rank,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
cache := newViewerPeerCache(r)
updates := r.channelParticipantUpdatesWithPeerCache(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
})
return updates, nil
}
func (r *Router) onMessagesSetChatTheme(ctx context.Context, req *tg.MessagesSetChatThemeRequest) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err == nil {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
view, err := r.deps.Channels.GetChannel(ctx, userID, channelID)
if err != nil {
return nil, channelInvalidErr(err)
}
return r.channelStateUpdates(userID, view.Channel), nil
} else if _, ok := channelIDFromLegacyInputPeer(userID, req.Peer); ok {
return nil, err
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
emoticon, err := chatThemeEmoticonFromInput(req.Theme)
if err != nil {
return nil, err
}
if r.deps.Messages == nil {
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.SetChatTheme(ctx, userID, domain.SetPrivateChatThemeRequest{
OwnerUserID: userID,
Peer: peer,
Emoticon: emoticon,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
return nil, peerIDInvalidErr()
}
return nil, internalErr()
}
if res.Changed {
r.invalidateRPCProjectionForPeer(userID, peer)
r.invalidateRPCProjectionForPeer(peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
if !res.Changed || res.Send.SenderMessage.ID == 0 {
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}
return tgPrivateMessageUpdates(
res.Send.SenderEvent,
res.Send.SenderMessage,
0,
false,
r.usersForMessageUpdate(ctx, userID, res.Send.SenderMessage),
[]tg.ChatClass{},
), nil
}
func chatThemeEmoticonFromInput(theme tg.InputChatThemeClass) (string, error) {
switch value := theme.(type) {
case *tg.InputChatThemeEmpty:
return "", nil
case *tg.InputChatTheme:
if value.Emoticon == "" {
return "", nil
}
if !tdesktop.IsChatThemeEmoticon(value.Emoticon) {
return "", themeInvalidErr()
}
return value.Emoticon, nil
case *tg.InputChatThemeUniqueGift:
return "", themeInvalidErr()
default:
return "", inputConstructorInvalidErr()
}
}
func (r *Router) onMessagesSetChatWallPaper(ctx context.Context, req *tg.MessagesSetChatWallPaperRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputConstructorInvalidErr()
}
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
}
wallpaper, err := domainWallpaperFromSetChatWallPaper(req)
if err != nil {
return nil, err
}
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID == 0 {
return nil, peerIDInvalidErr()
}
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
case domain.PeerTypeChannel:
if r.deps.Channels == nil || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
res, err := r.deps.Channels.SetWallpaper(ctx, userID, domain.SetChannelWallpaperRequest{
UserID: userID,
ChannelID: peer.ID,
Wallpaper: wallpaper,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
if res.Changed && res.Event.Pts != 0 {
r.enqueueChannelWallpaperFanout(ctx, userID, res)
} else if res.Changed {
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelWallpaperUpdatesWithPeerCache(ctx, viewerUserID, res, nil)
})
}
return r.channelWallpaperUpdatesWithPeerCache(ctx, userID, res, nil), nil
default:
return nil, peerIDInvalidErr()
}
}
func (r *Router) channelWallpaperUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.SetChannelWallpaperResult, cache *viewerPeerCache) *tg.Updates {
update := tgUpdatePeerWallpaper(domain.Peer{Type: domain.PeerTypeChannel, ID: res.Channel.ID}, res.Channel.Wallpaper)
if res.Event.Pts != 0 {
sendRes := domain.SendChannelMessageResult{
Channel: res.Channel,
Message: res.Message,
Event: res.Event,
Recipients: res.Recipients,
}
updates := r.channelMessageUpdatesWithPeerCache(ctx, viewerUserID, sendRes, 0, cache)
updates.Updates = append(updates.Updates, update)
return updates
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func (r *Router) enqueueChannelWallpaperFanout(ctx context.Context, originUserID int64, res domain.SetChannelWallpaperResult) {
sendRes := domain.SendChannelMessageResult{
Channel: res.Channel,
Message: res.Message,
Event: res.Event,
Recipients: res.Recipients,
}
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(sendRes, nil)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelWallpaperUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache)
})
}
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()
}
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
}
policy, err := domainChannelReactionPolicy(req)
if err != nil {
return nil, err
}
channel, err := r.deps.Channels.SetAvailableReactions(ctx, userID, channelID, policy)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
func legacyBasicGroupAdminRights() tg.ChatAdminRights {
return tg.ChatAdminRights{
ChangeInfo: true,
DeleteMessages: true,
BanUsers: true,
InviteUsers: true,
PinMessages: true,
Other: true,
}
}

View file

@ -0,0 +1,176 @@
package rpc
import (
"context"
"fmt"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestLegacyChannelSettingsRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550002181", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550002182", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Legacy Settings Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
themeUpdates, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{Peer: peer})
if err != nil {
t.Fatalf("set chat theme channel peer: %v", err)
}
if len(themeUpdates.(*tg.Updates).Chats) != 1 {
t.Fatalf("set chat theme updates = %+v, want channel context", themeUpdates)
}
privateTheme, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Theme: &tg.InputChatThemeEmpty{},
})
if err != nil {
t.Fatalf("set chat theme private peer: %v", err)
}
if len(privateTheme.(*tg.Updates).Updates) != 0 {
t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme)
}
reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}},
ReactionsLimit: 8,
})
if err != nil {
t.Fatalf("set available reactions: %v", err)
}
if len(reactionUpdates.(*tg.Updates).Chats) != 1 {
t.Fatalf("set reactions updates = %+v, want channel state update", reactionUpdates)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after reactions: %v", err)
}
fullChannel := full.FullChat.(*tg.ChannelFull)
reactions, ok := fullChannel.GetAvailableReactions()
if !ok {
t.Fatalf("full channel reactions missing after set")
}
some, ok := reactions.(*tg.ChatReactionsSome)
if !ok || len(some.Reactions) != 1 {
t.Fatalf("full channel reactions = %#v, want one explicit reaction", reactions)
}
if fullChannel.ReactionsLimit != 8 {
t.Fatalf("full channel reactions limit = %d, want 8", fullChannel.ReactionsLimit)
}
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: make([]tg.ReactionClass, domain.MaxChannelReactionTypes+1)},
}); err == nil {
t.Fatalf("set too many reactions err = nil, want limit error")
}
noForwards, err := r.onMessagesToggleNoForwards(WithUserID(ctx, owner.ID), &tg.MessagesToggleNoForwardsRequest{
Peer: peer,
Enabled: true,
})
if err != nil {
t.Fatalf("toggle noforwards: %v", err)
}
if got := noForwards.(*tg.Updates).Chats[0].(*tg.Channel); !got.Noforwards {
t.Fatalf("noforwards channel = %+v, want enabled", got)
}
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: peer,
Message: "protected content",
RandomID: 8181,
})
if err != nil {
t.Fatalf("send protected message: %v", err)
}
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if !msg.Noforwards {
t.Fatalf("protected channel message = %+v, want noforwards inherited", msg)
}
}
// TestBroadcastChannelAcceptsFullReactionCatalog 复现并锁定真机 bug广播频道开启
// reactions 时DrKLO 把「启用全部标准 reaction」发成显式 chatReactionsSome 列表
// megagroup 走 chatReactionsAll列表长度等于 getAvailableReactions 目录大小
// (当前 ~74。此前 MaxChannelReactionItems=64 把它误判成 LIMIT_INVALID。修复后
// 任何不超过 MaxChannelReactionTypes 的列表都必须被接受。
func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002191", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Broadcast Reactions",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
if channel.Megagroup {
t.Fatalf("created channel = %+v, want broadcast (not megagroup)", channel)
}
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
// 用一个明显超过旧上限(64)的目录大小,证明修复生效。每个 emoticon 取互不相同
// 的合法短串即可(验证只关心非空且 rune 数 <= MaxChannelReactionEmoticonLength
const catalogSize = 74
if catalogSize <= 64 || catalogSize > domain.MaxChannelReactionTypes {
t.Fatalf("test catalog size %d must exceed the old 64 cap and stay within %d",
catalogSize, domain.MaxChannelReactionTypes)
}
reactions := make([]tg.ReactionClass, 0, catalogSize)
for i := 0; i < catalogSize; i++ {
reactions = append(reactions, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%02d", i)})
}
updates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: reactions},
ReactionsLimit: 11,
})
if err != nil {
t.Fatalf("set full-catalog reactions on broadcast channel: %v", err)
}
if len(updates.(*tg.Updates).Chats) != 1 {
t.Fatalf("set reactions updates = %+v, want channel state update", updates)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after reactions: %v", err)
}
fullChannel := full.FullChat.(*tg.ChannelFull)
stored, ok := fullChannel.GetAvailableReactions()
if !ok {
t.Fatalf("full channel reactions missing after set")
}
some, ok := stored.(*tg.ChatReactionsSome)
if !ok || len(some.Reactions) != catalogSize {
t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize)
}
}

View file

@ -0,0 +1,401 @@
package rpc
import (
"context"
"strings"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestChannelMemberRankRPC 覆盖成员 Tagrank-only 编辑)的权限矩阵:
// actor ∈ {creator, manage_ranks admin, 普通成员} × target ∈ {自己, 普通成员,
// 自己提拔的 admin, 他人提拔的 admin, creator} × 群级 edit_rank 开关 {开, 关}
// 以及 editAdmin 撤管清 rank 的旧语义回归。
func TestChannelMemberRankRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002401", FirstName: "Owner"})
tagger, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002402", FirstName: "Tagger"})
plain, _ := userStore.Create(ctx, domain.User{AccessHash: 73, Phone: "15550002403", FirstName: "Plain"})
other, _ := userStore.Create(ctx, domain.User{AccessHash: 74, Phone: "15550002404", FirstName: "Other"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: tagger.ID, AccessHash: tagger.AccessHash},
&tg.InputUser{UserID: plain.ID, AccessHash: plain.AccessHash},
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
},
Title: "Member Tag Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
editRank := func(actorID int64, participant tg.InputPeerClass, rank string) (tg.UpdatesClass, error) {
return r.onMessagesEditChatParticipantRank(WithUserID(ctx, actorID), &tg.MessagesEditChatParticipantRankRequest{
Peer: peer,
Participant: participant,
Rank: rank,
})
}
getParticipant := func(viewerID, targetID int64) tg.ChannelParticipantClass {
t.Helper()
res, err := r.onChannelsGetParticipant(WithUserID(ctx, viewerID), &tg.ChannelsGetParticipantRequest{
Channel: inputChannel,
Participant: &tg.InputPeerUser{UserID: targetID},
})
if err != nil {
t.Fatalf("get participant %d: %v", targetID, err)
}
return res.Participant
}
// creator 给普通成员设 tag角色不变、rank 持久、推送 updateChannelParticipant。
rankUpdates, err := editRank(owner.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, "navigator")
if err != nil {
t.Fatalf("creator set member tag: %v", err)
}
participantUpdate := tg.UpdateClass(nil)
for _, update := range rankUpdates.(*tg.Updates).Updates {
if _, ok := update.(*tg.UpdateChannelParticipant); ok {
participantUpdate = update
break
}
}
if participantUpdate == nil {
t.Fatalf("rank updates = %+v, want updateChannelParticipant", rankUpdates.(*tg.Updates).Updates)
}
newParticipant, ok := participantUpdate.(*tg.UpdateChannelParticipant).GetNewParticipant()
if !ok {
t.Fatalf("rank update missing new participant: %+v", participantUpdate)
}
taggedMember, ok := newParticipant.(*tg.ChannelParticipant)
if !ok {
t.Fatalf("tagged member = %T, want plain channelParticipant (role must not change)", newParticipant)
}
if rank, _ := taggedMember.GetRank(); rank != "navigator" {
t.Fatalf("tagged member rank = %q, want navigator", rank)
}
if got, ok := getParticipant(owner.ID, plain.ID).(*tg.ChannelParticipant); !ok {
t.Fatalf("plain participant after tag = %T, want channelParticipant", getParticipant(owner.ID, plain.ID))
} else if rank, _ := got.GetRank(); rank != "navigator" {
t.Fatalf("plain participant rank = %q, want navigator", rank)
}
// admins filter 是两端消息徽章的数据源:必须返回管理员 + 带 tag 的普通成员。
adminsRes, err := r.onChannelsGetParticipants(WithUserID(ctx, other.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputChannel,
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 100,
})
if err != nil {
t.Fatalf("get admins participants: %v", err)
}
badgeRanks := map[int64]string{}
for _, participant := range adminsRes.(*tg.ChannelsChannelParticipants).Participants {
switch p := participant.(type) {
case *tg.ChannelParticipantCreator:
badgeRanks[p.UserID], _ = p.GetRank()
case *tg.ChannelParticipantAdmin:
badgeRanks[p.UserID], _ = p.GetRank()
case *tg.ChannelParticipant:
rank, _ := p.GetRank()
badgeRanks[p.UserID] = rank
}
}
if len(badgeRanks) != 2 {
t.Fatalf("admins filter participants = %+v, want creator + tagged plain member", badgeRanks)
}
if badgeRanks[plain.ID] != "navigator" {
t.Fatalf("admins filter plain rank = %q, want navigator (message badge source)", badgeRanks[plain.ID])
}
if _, ok := badgeRanks[other.ID]; ok {
t.Fatalf("admins filter must not contain untagged plain member: %+v", badgeRanks)
}
// 普通成员改自己开关默认开inputPeerSelf 路径)。
if _, err := editRank(plain.ID, &tg.InputPeerSelf{}, "pilot"); err != nil {
t.Fatalf("plain member edits own tag: %v", err)
}
if self, ok := getParticipant(plain.ID, plain.ID).(*tg.ChannelParticipantSelf); !ok {
t.Fatalf("self participant = %T, want channelParticipantSelf", getParticipant(plain.ID, plain.ID))
} else if rank, _ := self.GetRank(); rank != "pilot" {
t.Fatalf("self rank = %q, want pilot", rank)
}
// 普通成员改别人 → CHAT_ADMIN_REQUIRED。
if _, err := editRank(plain.ID, &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, "x"); err == nil || !strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") {
t.Fatalf("plain member edits other tag err = %v, want CHAT_ADMIN_REQUIRED", err)
}
// creator 授予 tagger 管理 Tags 的管理员权限manage_ranks 必须持久化并下发。
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: inputChannel,
UserID: &tg.InputUser{UserID: tagger.ID, AccessHash: tagger.AccessHash},
AdminRights: tg.ChatAdminRights{
ChangeInfo: true,
AddAdmins: true,
ManageRanks: true,
},
}); err != nil {
t.Fatalf("promote tagger with manage_ranks: %v", err)
}
taggerAdmin, ok := getParticipant(owner.ID, tagger.ID).(*tg.ChannelParticipantAdmin)
if !ok {
t.Fatalf("tagger participant = %T, want channelParticipantAdmin", getParticipant(owner.ID, tagger.ID))
}
if !taggerAdmin.AdminRights.ManageRanks {
t.Fatalf("tagger admin rights = %+v, want manage_ranks=true round-trip", taggerAdmin.AdminRights)
}
chats, err := r.onChannelsGetChannels(WithUserID(ctx, tagger.ID), []tg.InputChannelClass{inputChannel})
if err != nil {
t.Fatalf("get channels as tagger: %v", err)
}
taggerChat := chats.GetChats()[0].(*tg.Channel)
if rights, ok := taggerChat.GetAdminRights(); !ok || !rights.ManageRanks {
t.Fatalf("tagger chat admin rights = %+v, want manage_ranks=true", taggerChat)
}
// manage_ranks admin 改普通成员 → OK。
if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, "lookout"); err != nil {
t.Fatalf("manage_ranks admin edits plain member: %v", err)
}
// manage_ranks admin 改 creator → USER_CREATOR。
if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, "boss"); err == nil || !strings.Contains(err.Error(), "USER_CREATOR") {
t.Fatalf("admin edits creator tag err = %v, want USER_CREATOR", err)
}
// manage_ranks admin 改自己 → OK。
if _, err := editRank(tagger.ID, &tg.InputPeerSelf{}, "chief"); err != nil {
t.Fatalf("admin edits own tag: %v", err)
}
// tagger 提拔 otherpromoted_by=tagger后可改其 tagcreator 重新提拔后不可。
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, tagger.ID), &tg.ChannelsEditAdminRequest{
Channel: inputChannel,
UserID: &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
AdminRights: tg.ChatAdminRights{ChangeInfo: true},
}); err != nil {
t.Fatalf("tagger promotes other: %v", err)
}
if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, "scout"); err != nil {
t.Fatalf("admin edits tag of admin they promoted: %v", err)
}
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: inputChannel,
UserID: &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
AdminRights: tg.ChatAdminRights{ChangeInfo: true},
Rank: "scout",
}); err != nil {
t.Fatalf("creator re-promotes other: %v", err)
}
if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, "spy"); err == nil || !strings.Contains(err.Error(), "RIGHT_FORBIDDEN") {
t.Fatalf("admin edits tag of admin promoted by creator err = %v, want RIGHT_FORBIDDEN", err)
}
// 关闭群级 Member Tags 开关default_banned_rights.edit_rank=true
if _, err := r.onMessagesEditChatDefaultBannedRights(WithUserID(ctx, owner.ID), &tg.MessagesEditChatDefaultBannedRightsRequest{
Peer: peer,
BannedRights: tg.ChatBannedRights{EditRank: true},
}); err != nil {
t.Fatalf("disable member tags switch: %v", err)
}
chatsAfterSwitch, err := r.onChannelsGetChannels(WithUserID(ctx, plain.ID), []tg.InputChannelClass{inputChannel})
if err != nil {
t.Fatalf("get channels after switch: %v", err)
}
switchedChat := chatsAfterSwitch.GetChats()[0].(*tg.Channel)
if rights, ok := switchedChat.GetDefaultBannedRights(); !ok || !rights.EditRank {
t.Fatalf("default banned rights after switch = %+v, want edit_rank=true round-trip", switchedChat)
}
// 开关关闭后:普通成员改自己 → RIGHT_FORBIDDENadmin/creator 改自己仍可。
if _, err := editRank(plain.ID, &tg.InputPeerSelf{}, "pilot2"); err == nil || !strings.Contains(err.Error(), "RIGHT_FORBIDDEN") {
t.Fatalf("plain member edits own tag with switch off err = %v, want RIGHT_FORBIDDEN", err)
}
if _, err := editRank(tagger.ID, &tg.InputPeerSelf{}, "chief2"); err != nil {
t.Fatalf("admin edits own tag with switch off: %v", err)
}
if _, err := editRank(owner.ID, &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, "captain"); err != nil {
t.Fatalf("creator edits own tag: %v", err)
}
if got, ok := getParticipant(owner.ID, owner.ID).(*tg.ChannelParticipantCreator); !ok {
t.Fatalf("creator participant = %T, want channelParticipantCreator", getParticipant(owner.ID, owner.ID))
} else if rank, _ := got.GetRank(); rank != "captain" {
t.Fatalf("creator rank = %q, want captain", rank)
}
// rank 超长 → 拒绝。
if _, err := editRank(owner.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, strings.Repeat("r", domain.MaxChannelAdminRankLength+1)); err == nil {
t.Fatalf("over-length rank accepted, want error")
}
// editAdmin 撤管仍清空 rank旧语义回归
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: inputChannel,
UserID: &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
AdminRights: tg.ChatAdminRights{},
}); err != nil {
t.Fatalf("demote other: %v", err)
}
demoted, ok := getParticipant(owner.ID, other.ID).(*tg.ChannelParticipant)
if !ok {
t.Fatalf("demoted participant = %T, want channelParticipant", getParticipant(owner.ID, other.ID))
}
if rank, has := demoted.GetRank(); has && rank != "" {
t.Fatalf("demoted rank = %q, want cleared", rank)
}
// admin logrank 编辑必须记 participant_edit_rank且 edit_rank filter 可检索。
filter := tg.ChannelAdminLogEventsFilter{}
filter.SetEditRank(true)
logReq := &tg.ChannelsGetAdminLogRequest{Channel: inputChannel, Limit: 50}
logReq.SetEventsFilter(filter)
adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), logReq)
if err != nil {
t.Fatalf("get admin log: %v", err)
}
if len(adminLog.Events) == 0 {
t.Fatalf("admin log with edit_rank filter is empty, want participant_edit_rank events")
}
// 重进是全新 participantadmin带 manage_ranks 与 tag退群重进后是普通
// 成员、无 admin rights、无 rank且不再出现在 admins filter徽章数据源
if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, tagger.ID), inputChannel); err != nil {
t.Fatalf("tagger leaves: %v", err)
}
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, tagger.ID), inputChannel); err != nil {
t.Fatalf("tagger rejoins: %v", err)
}
rejoined, ok := getParticipant(owner.ID, tagger.ID).(*tg.ChannelParticipant)
if !ok {
t.Fatalf("rejoined tagger = %T, want plain channelParticipant", getParticipant(owner.ID, tagger.ID))
}
if rank, has := rejoined.GetRank(); has && rank != "" {
t.Fatalf("rejoined tagger rank = %q, want cleared", rank)
}
adminsAfterRejoin, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputChannel,
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 100,
})
if err != nil {
t.Fatalf("get admins after rejoin: %v", err)
}
for _, p := range adminsAfterRejoin.(*tg.ChannelsChannelParticipants).Participants {
if p, ok := p.(*tg.ChannelParticipantAdmin); ok && p.UserID == tagger.ID {
t.Fatalf("rejoined tagger still listed as admin in admins filter")
}
}
// kick→解禁→重进tag 不复活。
if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{
Channel: inputChannel,
Participant: &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash},
BannedRights: tg.ChatBannedRights{ViewMessages: true},
}); err != nil {
t.Fatalf("kick plain: %v", err)
}
if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{
Channel: inputChannel,
Participant: &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash},
BannedRights: tg.ChatBannedRights{},
}); err != nil {
t.Fatalf("unban plain: %v", err)
}
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, plain.ID), inputChannel); err != nil {
t.Fatalf("plain rejoins after unban: %v", err)
}
if got := getParticipant(owner.ID, plain.ID); true {
if p, ok := got.(*tg.ChannelParticipant); !ok {
t.Fatalf("unbanned plain participant = %T, want plain channelParticipant", got)
} else if rank, has := p.GetRank(); has && rank != "" {
t.Fatalf("unbanned plain rank = %q, want cleared", rank)
}
}
// creator 离开会把 owner 交给其他活跃成员;旧 creator 重进是普通成员,
// 不保留 creator tag。
if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil {
t.Fatalf("creator leaves: %v", err)
}
newCreator, ok := getParticipant(tagger.ID, tagger.ID).(*tg.ChannelParticipantCreator)
if !ok {
t.Fatalf("future creator = %T, want channelParticipantCreator", getParticipant(tagger.ID, tagger.ID))
}
if rank, has := newCreator.GetRank(); has && rank != "" {
t.Fatalf("future creator rank = %q, want empty", rank)
}
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil {
t.Fatalf("creator rejoins: %v", err)
}
creatorBack, ok := getParticipant(tagger.ID, owner.ID).(*tg.ChannelParticipant)
if !ok {
t.Fatalf("rejoined old creator = %T, want plain channelParticipant", getParticipant(tagger.ID, owner.ID))
}
if rank, has := creatorBack.GetRank(); has && rank != "" {
t.Fatalf("rejoined old creator rank = %q, want cleared", rank)
}
// 成员 Tag 是 megagroup 专属broadcast 频道上任何路径(含 creator/self
// 一律 MEGAGROUP_ID_INVALIDbroadcast 的 admins filter 才能保持纯管理员列表。
bcCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Broadcast: true,
Title: "Member Tag Broadcast",
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
bcChannel := bcCreated.(*tg.Updates).Chats[0].(*tg.Channel)
bcInput := &tg.InputChannel{ChannelID: bcChannel.ID, AccessHash: bcChannel.AccessHash}
bcPeer := &tg.InputPeerChannel{ChannelID: bcChannel.ID, AccessHash: bcChannel.AccessHash}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: bcInput,
Users: []tg.InputUserClass{&tg.InputUser{UserID: plain.ID, AccessHash: plain.AccessHash}},
}); err != nil {
t.Fatalf("invite to broadcast: %v", err)
}
bcEditRank := func(actorID int64, participant tg.InputPeerClass, rank string) error {
_, err := r.onMessagesEditChatParticipantRank(WithUserID(ctx, actorID), &tg.MessagesEditChatParticipantRankRequest{
Peer: bcPeer,
Participant: participant,
Rank: rank,
})
return err
}
for name, attempt := range map[string]func() error{
"creator tags subscriber": func() error {
return bcEditRank(owner.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, "vip")
},
"subscriber tags self": func() error { return bcEditRank(plain.ID, &tg.InputPeerSelf{}, "fan") },
"creator tags self": func() error { return bcEditRank(owner.ID, &tg.InputPeerSelf{}, "boss") },
} {
if err := attempt(); err == nil || !strings.Contains(err.Error(), "MEGAGROUP_ID_INVALID") {
t.Fatalf("%s on broadcast err = %v, want MEGAGROUP_ID_INVALID", name, err)
}
}
for _, event := range adminLog.Events {
action, ok := event.Action.(*tg.ChannelAdminLogEventActionParticipantEditRank)
if !ok {
t.Fatalf("admin log action = %T, want channelAdminLogEventActionParticipantEditRank", event.Action)
}
if action.UserID == 0 {
t.Fatalf("admin log edit rank action missing user: %+v", action)
}
}
latest := adminLog.Events[0].Action.(*tg.ChannelAdminLogEventActionParticipantEditRank)
if latest.UserID != owner.ID || latest.NewRank != "captain" {
t.Fatalf("latest edit rank action = %+v, want owner captain", latest)
}
}

View file

@ -0,0 +1,830 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap"
"sort"
"strings"
"telesrv/internal/domain"
"unicode/utf8"
)
func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg.ChannelsGetAdminedPublicChannelsRequest) (tg.MessagesChatsClass, error) {
if r.deps.Channels == nil {
return &tg.MessagesChats{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req.ByLocation {
return &tg.MessagesChats{}, nil
}
channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
}
func (r *Router) onChannelsDeleteParticipantHistory(ctx context.Context, req *tg.ChannelsDeleteParticipantHistoryRequest) (*tg.MessagesAffectedHistory, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
peer, ok := r.domainPeerFromInputPeer(0, req.Participant)
if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
res, err := r.deps.Channels.DeleteParticipantHistory(ctx, userID, domain.DeleteChannelParticipantHistoryRequest{
UserID: userID,
ChannelID: channelID,
ParticipantUserID: peer.ID,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelDeleteErr(err)
}
if res.Event.Pts != 0 {
// deleteParticipantHistory fan-out 异步化(设计 Phase 0与已异步的 messages.deleteMessages
// 频道分支对齐。builder 纯 viewer 无关(仅 delete update + ChatMin无 Users无需预热。
r.enqueueChannelFanout(ctx, channelFanoutMembers, 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.Channel.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil
}
func (r *Router) onChannelsToggleJoinToSend(ctx context.Context, req *tg.ChannelsToggleJoinToSendRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetJoinToSend(ctx, userID, channelID, req.Enabled)
})
}
func (r *Router) onChannelsToggleJoinRequest(ctx context.Context, req *tg.ChannelsToggleJoinRequestRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetJoinRequest(ctx, userID, channelID, req.Enabled)
})
}
func (r *Router) onChannelsToggleParticipantsHidden(ctx context.Context, req *tg.ChannelsToggleParticipantsHiddenRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetParticipantsHidden(ctx, userID, channelID, req.Enabled)
})
}
func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.ChannelsGetMessageAuthorRequest) (tg.UserClass, error) {
userID, view, err := r.channelView(ctx, req.Channel)
if err != nil {
return nil, err
}
author, err := r.deps.Channels.GetMessageAuthor(ctx, userID, domain.GetChannelMessageAuthorRequest{
UserID: userID,
ChannelID: view.Channel.ID,
ID: req.ID,
})
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
return nil, messageIDInvalidErr()
}
return nil, channelInvalidErr(err)
}
users := r.tgUsersForIDs(ctx, userID, []int64{author.SenderUserID})
if len(users) == 0 {
return nil, peerIDInvalidErr()
}
return users[0], nil
}
func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) {
if r.deps.Channels == nil {
return &tg.ChannelsChannelParticipants{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
ref, ok := inputChannelRef(req.Channel)
if !ok {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
filter := domainChannelParticipantsFilter(req.Filter)
if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
return nil, limitInvalidErr()
}
list, err := r.deps.Channels.GetParticipants(ctx, userID, ref.ID, filter, req.Offset, req.Limit)
if err != nil {
return nil, channelInvalidErr(err)
}
if !inputChannelAccessHashMatches(ref, list.Channel) {
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
if req.Hash != 0 && list.Hash == req.Hash {
return &tg.ChannelsChannelParticipantsNotModified{}, nil
}
participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants))
userIDs := make([]int64, 0, len(list.Participants))
seenUserIDs := make(map[int64]struct{}, len(list.Participants)*2)
addUserID := func(id int64) {
if id == 0 {
return
}
if _, ok := seenUserIDs[id]; ok {
return
}
seenUserIDs[id] = struct{}{}
userIDs = append(userIDs, id)
}
for _, member := range list.Participants {
participant := tgChannelParticipant(userID, member)
participants = append(participants, participant)
for _, id := range channelParticipantUserRefs(participant) {
addUserID(id)
}
}
users := r.tgUsers(list.Users)
if len(users) == 0 {
users = r.tgUsersForIDs(ctx, userID, userIDs)
} else {
present := make(map[int64]struct{}, len(users))
for _, item := range users {
if u, ok := item.(*tg.User); ok {
present[u.ID] = struct{}{}
}
}
missing := make([]int64, 0, len(userIDs))
for _, id := range userIDs {
if _, ok := present[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
users = append(users, r.tgUsersForIDs(ctx, userID, missing)...)
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil)
r.log.Debug("channels.getParticipants result",
zap.Int64("channel_id", ref.ID),
zap.String("filter", string(filter.Kind)),
zap.Int("count", list.Count),
zap.Int("participants", len(participants)),
zap.Int("users", len(users)),
)
return &tg.ChannelsChannelParticipants{
Count: list.Count,
Participants: participants,
Chats: []tg.ChatClass{},
Users: users,
}, nil
}
func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsGetParticipantRequest) (*tg.ChannelsChannelParticipant, error) {
if r.deps.Channels == nil {
return &tg.ChannelsChannelParticipant{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Participant)
if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, peer.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
participant := tgChannelParticipant(userID, member)
users := r.tgUsersForIDs(ctx, userID, channelParticipantUserRefs(participant))
r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil)
return &tg.ChannelsChannelParticipant{
Participant: participant,
Users: users,
}, nil
}
func channelParticipantUserRefs(participant tg.ChannelParticipantClass) []int64 {
ids := make([]int64, 0, 3)
add := func(id int64) {
if id == 0 {
return
}
for _, existing := range ids {
if existing == id {
return
}
}
ids = append(ids, id)
}
addPeer := func(peer tg.PeerClass) {
if p, ok := peer.(*tg.PeerUser); ok {
add(p.UserID)
}
}
switch p := participant.(type) {
case *tg.ChannelParticipantCreator:
add(p.UserID)
case *tg.ChannelParticipantAdmin:
add(p.UserID)
add(p.PromotedBy)
if inviterID, ok := p.GetInviterID(); ok {
add(inviterID)
}
case *tg.ChannelParticipantSelf:
add(p.UserID)
add(p.InviterID)
case *tg.ChannelParticipant:
add(p.UserID)
case *tg.ChannelParticipantLeft:
addPeer(p.Peer)
case *tg.ChannelParticipantBanned:
addPeer(p.Peer)
add(p.KickedBy)
}
return ids
}
func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.ChannelsInviteToChannelRequest) (*tg.MessagesInvitedUsers, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if len(req.Users) == 0 || len(req.Users) > domain.MaxChannelInviteUsers {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
userIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix()))
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
cache := newViewerPeerCache(r)
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, res, cache)
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
}
func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
ref, ok := inputChannelRef(input)
if !ok {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
if ref.CheckAccessHash {
channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
if !inputChannelAccessHashMatches(ref, channel) {
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
}
res, err := r.deps.Channels.JoinChannel(ctx, userID, ref.ID, int(r.clock.Now().Unix()))
if err != nil {
if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 {
r.pushPendingJoinRequestsToAdmins(ctx, res.Channel)
}
return nil, channelInviteErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
// Layer 227channels.joinChannel 返回 messages.ChatInviteJoinResult
// 正常加入即 chatInviteJoinResultOk 包裹本次操作的 updates。
return &tg.MessagesChatInviteJoinResultOk{Updates: updates}, nil
}
func (r *Router) onChannelsLeaveChannel(ctx context.Context, input tg.InputChannelClass) (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.channelIDFromInput(ctx, userID, input)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.LeaveChannel(ctx, userID, channelID, int(r.clock.Now().Unix()))
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
r.removeOnlineChannelMemberships(res.Channel.ID, userID)
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
updates := r.channelOperationUpdates(ctx, userID, res)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
return updates, nil
}
// recordChannelStateForUser 给该账号写一条 durable channel 状态事件:
// 离线设备经 difference 收到 updateChannel 后重拉 channel发现 left/
// forbidden 并移除会话excludeCurrent 时当前 session 由 RPC 响应承担。
func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelID int64, excludeCurrent bool) {
if r.deps.Updates == nil || userID == 0 || channelID == 0 {
return
}
authKeyID := [8]byte{}
excludeSessionID := int64(0)
if excludeCurrent {
authKeyID, _ = AuthKeyIDFrom(ctx)
excludeSessionID, _ = SessionIDFrom(ctx)
}
event, _, err := r.deps.Updates.RecordChannelState(ctx, authKeyID, userID, channelID, excludeSessionID)
if err != nil {
return
}
if excludeCurrent {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
}
func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (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.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found || target.ID == 0 {
return nil, peerIDInvalidErr()
}
res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{
UserID: userID,
ChannelID: channelID,
MemberID: target.ID,
AdminRights: domainChannelAdminRights(req.AdminRights),
Rank: req.Rank,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
if res.Participant.Status == domain.ChannelMemberActive {
r.addOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID)
} else {
r.removeOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID)
}
cache := newViewerPeerCache(r)
updates := r.channelParticipantUpdatesWithPeerCache(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
})
return updates, nil
}
func (r *Router) onChannelsEditBanned(ctx context.Context, req *tg.ChannelsEditBannedRequest) (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.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
participant, ok := r.domainPeerFromInputPeer(userID, req.Participant)
if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 {
return nil, peerIDInvalidErr()
}
res, err := r.deps.Channels.EditBanned(ctx, userID, domain.EditChannelBannedRequest{
UserID: userID,
ChannelID: channelID,
Participant: participant,
BannedRights: domainChannelBannedRights(req.BannedRights),
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
if res.Participant.Status == domain.ChannelMemberKicked && res.Previous.Status == domain.ChannelMemberActive {
r.recordChannelStateForUser(ctx, res.Participant.UserID, res.Channel.ID, false)
}
cache := newViewerPeerCache(r)
build := func(viewerUserID int64) *tg.Updates {
updates := r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache)
if updates != nil && res.ServiceEvent.Pts != 0 {
// megagroup 踢人服务消息占 channel pts必须先于 participant
// update 应用,让成员面板/人数与消息流一起收敛。
if update := tgChannelUpdate(viewerUserID, res.ServiceEvent); update != nil {
updates.Updates = append([]tg.UpdateClass{update}, updates.Updates...)
}
}
return updates
}
updates := build(userID)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, build)
return updates, nil
}
func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetAdminLogRequest) (*tg.ChannelsAdminLogResults, error) {
if r.deps.Channels == nil {
return &tg.ChannelsAdminLogResults{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
adminIDs := []int64(nil)
if admins, ok := req.GetAdmins(); ok && len(admins) > 0 {
if len(admins) > domain.MaxChannelAdminLogAdmins {
return nil, limitInvalidErr()
}
adminIDs, err = r.userIDsFromInputUsers(ctx, userID, admins)
if err != nil {
return nil, err
}
}
res, err := r.deps.Channels.ListAdminLog(ctx, userID, domain.ChannelAdminLogRequest{
UserID: userID,
ChannelID: channelID,
Query: req.Q,
AdminUserIDs: adminIDs,
MaxID: req.MaxID,
MinID: req.MinID,
Limit: req.Limit,
Filter: domainChannelAdminLogFilter(req),
})
if err != nil {
return nil, channelAdminErr(err)
}
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)
return &tg.ChannelsAdminLogResults{
Events: events,
Chats: chats,
Users: users,
}, nil
}
func (r *Router) onMessagesGetAdminsWithInvites(ctx context.Context, peer tg.InputPeerClass) (*tg.MessagesChatAdminsWithInvites, error) {
userID, view, err := r.inviteManagementChannelView(ctx, peer)
if err != nil {
return nil, err
}
counts, err := r.deps.Channels.ListAdminsWithInvites(ctx, userID, view.Channel.ID)
if err != nil {
return nil, channelInviteErr(err)
}
admins := make([]tg.ChatAdminWithInvites, 0, len(counts))
userIDs := make([]int64, 0, len(counts))
for _, count := range counts {
admins = append(admins, tg.ChatAdminWithInvites{
AdminID: count.AdminUserID,
InvitesCount: count.InvitesCount,
RevokedInvitesCount: count.RevokedInvitesCount,
})
userIDs = append(userIDs, count.AdminUserID)
}
return &tg.MessagesChatAdminsWithInvites{
Admins: admins,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
}, nil
}
func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.MessagesHideChatJoinRequestRequest) (tg.UpdatesClass, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
targets, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID})
if err != nil {
return nil, err
}
if len(targets) == 0 {
return nil, userIDInvalidErr()
}
res, err := r.deps.Channels.HideChatJoinRequest(ctx, userID, domain.HideChannelJoinRequestRequest{
UserID: userID,
ChannelID: view.Channel.ID,
TargetUserID: targets[0],
Approved: req.Approved,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
r.pushPendingJoinRequestsToAdmins(ctx, res.Channel)
return updates, nil
}
func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.MessagesHideAllChatJoinRequestsRequest) (tg.UpdatesClass, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
link, hasLink := req.GetLink()
if hasLink && len(link) > maxChatInviteLinkLength {
return nil, limitInvalidErr()
}
hash := ""
if hasLink && strings.TrimSpace(link) != "" {
hash, err = channelInviteHashFromLink(link)
if err != nil {
return nil, err
}
}
res, err := r.deps.Channels.HideAllChatJoinRequests(ctx, userID, domain.HideChannelJoinRequestsRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
Approved: req.Approved,
Limit: domain.MaxChannelHideJoinRequests,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
r.pushPendingJoinRequestsToAdmins(ctx, res.Channel)
return updates, nil
}
func canViewChannelJoinRequests(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator ||
(member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo))
}
func (r *Router) pendingJoinRequestsUpdates(ctx context.Context, viewerUserID int64, channel domain.Channel) *tg.Updates {
if r.deps.Channels == nil || channel.ID == 0 {
return nil
}
pending, err := r.deps.Channels.PendingJoinRequests(ctx, channel.ID, domain.MaxChannelPendingJoinRecentRequesters)
if err != nil {
return nil
}
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePendingJoinRequests{
Peer: &tg.PeerChannel{ChannelID: channel.ID},
RequestsPending: pending.Count,
RecentRequesters: pending.RecentRequesters,
}},
Users: r.tgUsersForIDs(ctx, viewerUserID, pending.RecentRequesters),
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func (r *Router) appendPendingJoinRequestsUpdate(ctx context.Context, viewerUserID int64, updates *tg.Updates, channel domain.Channel) {
if updates == nil {
return
}
pending := r.pendingJoinRequestsUpdates(ctx, viewerUserID, channel)
if pending == nil {
return
}
updates.Updates = append(updates.Updates, pending.Updates...)
updates.Users = append(updates.Users, pending.Users...)
}
func (r *Router) pushPendingJoinRequestsToAdmins(ctx context.Context, channel domain.Channel) {
if r.deps.Channels == nil || r.deps.Sessions == nil || channel.ID == 0 {
return
}
adminIDs, err := r.deps.Channels.InviteAdminMemberIDs(ctx, channel.ID, domain.MaxChannelRealtimeFanout)
if err != nil || len(adminIDs) == 0 {
adminIDs = []int64{channel.CreatorUserID}
}
seen := make(map[int64]struct{}, len(adminIDs))
for _, adminID := range adminIDs {
if adminID == 0 {
continue
}
if _, ok := seen[adminID]; ok {
continue
}
seen[adminID] = struct{}{}
updates := r.pendingJoinRequestsUpdates(ctx, adminID, channel)
if updates == nil {
continue
}
r.pushUserUpdates(ctx, adminID, updates)
}
}
func (r *Router) channelParticipantUpdates(ctx context.Context, viewerUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, date int) *tg.Updates {
return r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, actorUserID, channel, previous, participant, date, newViewerPeerCache(r))
}
func (r *Router) channelParticipantUpdatesWithPeerCache(ctx context.Context, viewerUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, date int, cache *viewerPeerCache) *tg.Updates {
if cache == nil {
cache = newViewerPeerCache(r)
}
update := &tg.UpdateChannelParticipant{
ChannelID: channel.ID,
Date: date,
ActorID: actorUserID,
UserID: participant.UserID,
}
if update.ActorID == 0 {
update.ActorID = viewerUserID
}
if previous.UserID != 0 {
update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, previous))
}
if participant.UserID != 0 {
update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, participant))
}
// 当事成员必须收到完整 channel 投影:被踢/被封禁状态只有通过非 min
// 对象的 left/banned_rights 才会被客户端应用min 形态会让被踢者
// 永远不知道自己已离开会话。其它接收者用 min 保护各自本地权限。
var chat tg.ChatClass
if viewerUserID != 0 && viewerUserID == participant.UserID {
self := participant
chat = tgChannelChat(viewerUserID, channel, &self)
} else {
chat = tgChannelChatMin(viewerUserID, channel)
}
return &tg.Updates{
Updates: []tg.UpdateClass{update, &tg.UpdateChannel{ChannelID: channel.ID}},
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, []int64{participant.UserID, participant.InviterUserID, previous.UserID, previous.InviterUserID, update.ActorID})),
Chats: []tg.ChatClass{chat},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter {
filter, ok := req.GetEventsFilter()
if !ok {
return domain.ChannelAdminLogFilter{}
}
return domain.ChannelAdminLogFilter{
Join: filter.GetJoin(),
Leave: filter.GetLeave(),
Invite: filter.GetInvite(),
Ban: filter.GetBan(),
Unban: filter.GetUnban(),
Kick: filter.GetKick(),
Unkick: filter.GetUnkick(),
Promote: filter.GetPromote(),
Demote: filter.GetDemote(),
Info: filter.GetInfo(),
Settings: filter.GetSettings(),
Pinned: filter.GetPinned(),
Edit: filter.GetEdit(),
Delete: filter.GetDelete(),
Send: filter.GetSend(),
Invites: filter.GetInvites(),
Forums: filter.GetForums(),
SubExtend: filter.GetSubExtend(),
EditRank: filter.GetEditRank(),
}
}
func (r *Router) channelAdminLogUsers(ctx context.Context, currentUserID int64, events []domain.ChannelAdminLogEvent) []tg.UserClass {
if r.deps.Users == nil || len(events) == 0 {
return nil
}
ids := make(map[int64]struct{}, len(events))
add := func(id int64) {
if id != 0 {
ids[id] = struct{}{}
}
}
addMember := func(member *domain.ChannelMember) {
if member != nil {
add(member.UserID)
add(member.InviterUserID)
}
}
addMessage := func(msg *domain.ChannelMessage) {
if msg != nil {
add(msg.SenderUserID)
if msg.From.Type == domain.PeerTypeUser {
add(msg.From.ID)
}
}
}
for _, event := range events {
add(event.UserID)
addMember(event.PrevParticipant)
addMember(event.NewParticipant)
addMember(event.Participant)
addMessage(event.Message)
addMessage(event.PrevMessage)
addMessage(event.NewMessage)
}
userIDs := make([]int64, 0, len(ids))
for id := range ids {
userIDs = append(userIDs, id)
}
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
return r.tgUsersForIDs(ctx, currentUserID, userIDs)
}
func domainChannelParticipantsFilter(filter tg.ChannelParticipantsFilterClass) domain.ChannelParticipantsFilter {
switch f := filter.(type) {
case *tg.ChannelParticipantsAdmins:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}
case *tg.ChannelParticipantsKicked:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked, Query: f.Q}
case *tg.ChannelParticipantsBanned:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBanned, Query: f.Q}
case *tg.ChannelParticipantsSearch:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsSearch, Query: f.Q}
case *tg.ChannelParticipantsBots:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots}
case *tg.ChannelParticipantsContacts:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsContacts, Query: f.Q}
case *tg.ChannelParticipantsMentions:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsMentions, Query: f.Q}
default:
return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}
}
}
func channelAdminErr(err error) error {
switch {
case errors.Is(err, domain.ErrChannelNotModified):
return tgerr400("CHAT_NOT_MODIFIED")
case errors.Is(err, domain.ErrChatPublicRequired):
return tgerr400("CHAT_PUBLIC_REQUIRED")
case errors.Is(err, domain.ErrChatDiscussionUnallowed):
return tgerr400("CHAT_DISCUSSION_UNALLOWED")
case errors.Is(err, domain.ErrChannelRightForbidden):
return tgerr.New(403, "RIGHT_FORBIDDEN")
case errors.Is(err, domain.ErrChannelUserCreator):
return tgerr400("USER_CREATOR")
case errors.Is(err, domain.ErrUserNotParticipant):
return tgerr400("USER_NOT_PARTICIPANT")
case errors.Is(err, domain.ErrMegagroupIDInvalid):
return tgerr400("MEGAGROUP_ID_INVALID")
case errors.Is(err, domain.ErrMessageIDInvalid):
return messageIDInvalidErr()
default:
return channelInvalidErr(err)
}
}

View file

@ -0,0 +1,408 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"sort"
"strconv"
"strings"
"telesrv/internal/domain"
"unicode/utf8"
)
func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) {
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
userID, view, err := r.channelView(ctx, req.Channel)
if err != nil {
return nil, err
}
history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{req.ID})
if err != nil {
return nil, channelInvalidErr(err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != req.ID {
return nil, messageIDInvalidErr()
}
link := ""
if view.Channel.Username != "" {
link = "https://telesrv.net/" + view.Channel.Username + "/" + strconv.Itoa(req.ID)
} else {
link = "https://telesrv.net/c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID)
}
if req.Thread {
if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID {
link += "?thread=" + strconv.Itoa(rootID)
}
}
return &tg.ExportedMessageLink{Link: link, HTML: ""}, nil
}
func channelMessageThreadRootID(msg domain.ChannelMessage) int {
if msg.ReplyTo == nil {
return 0
}
if msg.ReplyTo.TopMessageID > 0 {
return msg.ReplyTo.TopMessageID
}
return msg.ReplyTo.MessageID
}
func readChannelMessageContentIDs(messages []domain.ChannelMessage) []int {
if len(messages) == 0 {
return nil
}
ids := make([]int, 0, len(messages))
seen := make(map[int]struct{}, len(messages))
for _, msg := range messages {
if msg.ID <= 0 {
continue
}
if _, ok := seen[msg.ID]; ok {
continue
}
seen[msg.ID] = struct{}{}
ids = append(ids, msg.ID)
}
sort.Ints(ids)
return ids
}
func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSearchPostsRequest) (tg.MessagesMessagesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if err := validateChannelSearchPostsRequest(req); err != nil {
return nil, err
}
offsetChannelID, err := r.searchPostsOffsetChannelID(ctx, userID, req.OffsetPeer)
if err != nil {
return nil, err
}
if r.deps.Channels == nil {
return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
}
hashtag, query := channelSearchPostsTerms(req)
history, err := r.deps.Channels.SearchPosts(ctx, userID, domain.ChannelSearchPostsRequest{
Hashtag: hashtag,
Query: query,
OffsetRate: req.OffsetRate,
OffsetChannelID: offsetChannelID,
OffsetID: req.OffsetID,
Limit: req.Limit,
})
if err != nil {
return nil, channelInvalidErr(err)
}
history = r.enrichChannelHistory(ctx, userID, history)
return tgChannelSearchPostsMessages(userID, history), nil
}
func validateChannelSearchPostsRequest(req *tg.ChannelsSearchPostsRequest) error {
if req == nil {
return inputRequestInvalidErr()
}
if req.Limit < 0 || req.Limit > maxChannelSearchPostsLimit {
return limitInvalidErr()
}
if req.OffsetRate < 0 {
return limitInvalidErr()
}
if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID {
return messageIDInvalidErr()
}
if req.AllowPaidStars < 0 {
return limitInvalidErr()
}
hashtag, hasHashtag, query, hasQuery := channelSearchPostsTermsWithFlags(req)
if hasHashtag == hasQuery {
return searchQueryEmptyErr()
}
if hasHashtag {
if strings.TrimSpace(hashtag) == "" {
return searchQueryEmptyErr()
}
if strings.Contains(hashtag, "#") || utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery {
return limitInvalidErr()
}
}
if hasQuery {
if strings.TrimSpace(query) == "" {
return searchQueryEmptyErr()
}
if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery {
return limitInvalidErr()
}
}
return nil
}
func channelSearchPostsTerms(req *tg.ChannelsSearchPostsRequest) (hashtag, query string) {
hashtag, _, query, _ = channelSearchPostsTermsWithFlags(req)
return strings.TrimSpace(hashtag), strings.TrimSpace(query)
}
func channelSearchPostsTermsWithFlags(req *tg.ChannelsSearchPostsRequest) (hashtag string, hasHashtag bool, query string, hasQuery bool) {
hashtag, hasHashtag = req.GetHashtag()
if !hasHashtag && req.Hashtag != "" {
hashtag, hasHashtag = req.Hashtag, true
}
query, hasQuery = req.GetQuery()
if !hasQuery && req.Query != "" {
query, hasQuery = req.Query, true
}
return hashtag, hasHashtag, query, hasQuery
}
func (r *Router) searchPostsOffsetChannelID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) {
if peer == nil {
return 0, nil
}
if _, ok := peer.(*tg.InputPeerEmpty); ok {
return 0, nil
}
out, ok := r.domainPeerFromInputPeer(userID, peer)
if !ok || out.ID == 0 {
return 0, peerIDInvalidErr()
}
if out.Type != domain.PeerTypeChannel {
return 0, peerIDInvalidErr()
}
ref, ok := inputPeerChannelRef(peer)
if !ok || !ref.CheckAccessHash || r.deps.Channels == nil {
return out.ID, nil
}
view, err := r.deps.Channels.GetChannel(ctx, userID, out.ID)
if err == nil {
if !inputChannelAccessHashMatches(ref, view.Channel) {
return 0, channelInvalidErr(domain.ErrChannelPrivate)
}
return out.ID, nil
}
if !errors.Is(err, domain.ErrChannelPrivate) {
return 0, channelInvalidErr(err)
}
channel, joinErr := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID)
if joinErr != nil || channel.Username == "" || !inputChannelAccessHashMatches(ref, channel) {
return 0, channelInvalidErr(err)
}
return out.ID, nil
}
func tgChannelSearchPostsMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass {
messages := make([]tg.MessageClass, 0, len(history.Messages))
for _, msg := range history.Messages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
messages = append(messages, item)
}
}
chats := tgChannels(viewerUserID, history.Channels)
users := tgUsersForViewer(viewerUserID, history.Users) // viewer 自己的帖子作者须带 self 标志
if history.Count > len(messages) {
out := &tg.MessagesMessagesSlice{
Count: history.Count,
Messages: messages,
Topics: []tg.ForumTopicClass{},
Chats: chats,
Users: users,
}
if len(history.Messages) > 0 {
out.SetNextRate(history.Messages[len(history.Messages)-1].Date)
}
out.SetSearchFlood(tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0})
return out
}
return &tg.MessagesMessages{Messages: messages, Topics: []tg.ForumTopicClass{}, Chats: chats, Users: users}
}
func (r *Router) onChannelsCheckSearchPostsFlood(ctx context.Context, req *tg.ChannelsCheckSearchPostsFloodRequest) (*tg.SearchPostsFlood, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
if err := validateChannelCheckSearchPostsFloodRequest(req); err != nil {
return nil, err
}
return &tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}, nil
}
func validateChannelCheckSearchPostsFloodRequest(req *tg.ChannelsCheckSearchPostsFloodRequest) error {
if req == nil {
return inputRequestInvalidErr()
}
query, hasQuery := req.GetQuery()
if !hasQuery && req.Query != "" {
query, hasQuery = req.Query, true
}
if !hasQuery || strings.TrimSpace(query) == "" {
return searchQueryEmptyErr()
}
if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery {
return limitInvalidErr()
}
return nil
}
func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetMessagesRequest) (tg.MessagesMessagesClass, error) {
if len(req.ID) > domain.MaxGetMessageIDs {
return nil, limitInvalidErr()
}
if r.deps.Channels == nil || len(req.ID) == 0 {
return &tg.MessagesMessages{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
ids := make([]int, 0, len(req.ID))
for _, input := range req.ID {
id, ok := inputMessageBoxID(input)
if !ok || id <= 0 || id > domain.MaxMessageBoxID {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return &tg.MessagesMessages{}, nil
}
history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, ids)
if err != nil {
return nil, channelInvalidErr(err)
}
history = r.enrichChannelHistory(ctx, userID, history)
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
for _, msg := range history.Messages {
byID[msg.ID] = msg
}
messages := make([]tg.MessageClass, 0, len(ids))
for _, id := range ids {
if msg, ok := byID[id]; ok {
messages = append(messages, tgChannelMessage(userID, msg))
} else {
messages = append(messages, &tg.MessageEmpty{ID: id})
}
}
return &tg.MessagesMessages{
Messages: messages,
Chats: tgChannels(userID, []domain.Channel{history.Channel}),
Users: r.tgUsersForViewer(userID, history.Users), // viewer 补拉自己的消息(含置顶)须带 self
}, nil
}
func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) {
if len(req.ID) == 0 {
return &tg.MessagesAffectedMessages{PtsCount: 0}, nil
}
if len(req.ID) > domain.MaxDeleteMessageIDs {
return nil, limitInvalidErr()
}
for _, id := range req.ID {
if id <= 0 || id > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
}
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.DeleteMessages(ctx, userID, domain.DeleteChannelMessagesRequest{
UserID: userID,
ChannelID: channelID,
IDs: append([]int(nil), req.ID...),
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelDeleteErr(err)
}
if res.Event.Pts != 0 {
// 删除 fan-out 异步化(设计 Phase 0。channelDeleteMessagesUpdates 是纯 CPU 构建
// (不碰 PG、不取 ctxasync 无竞态;同 channel 串行保 pts 单调。
r.enqueueChannelFanout(ctx, channelFanoutMembers, 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 {
return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event)
})
}
return &tg.MessagesAffectedMessages{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount}, nil
}
return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil
}
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
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{
UserID: userID,
ChannelID: channelID,
MaxID: req.MaxID,
ForEveryone: req.GetForEveryone(),
Date: int(r.clock.Now().Unix()),
})
if err != nil {
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)
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 {
return r.channelDeleteMessagesUpdates(viewerUserID, batch.Channel, batch.Event)
})
return out
}
updates := pushBatch(res)
// channels.deleteHistory 返回 UpdatesTL 层没有 affectedHistory.offset
// 续删协议,客户端只发一次请求;超过单批上限的剩余历史必须由服务端
// 在本次请求内删完,否则"清空"后旧消息会残留。每批独立事务推进 pts
// 并立即推送,中途失败时已删批次保持有效,返回最后成功批次的结果。
for res.Offset != 0 && ctx.Err() == nil {
next, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{
UserID: userID,
ChannelID: channelID,
MaxID: req.MaxID,
ForEveryone: req.GetForEveryone(),
Date: int(r.clock.Now().Unix()),
})
if err != nil || next.Event.Pts == 0 {
break
}
res = next
updates = pushBatch(res)
}
return updates, nil
}

View file

@ -0,0 +1,937 @@
package rpc
import (
"context"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"strconv"
"strings"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appupdates "telesrv/internal/app/updates"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
func TestChannelsDeleteChannelReturnsForbiddenChatAndHidesDialogRPC(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)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
channelStore := memory.NewChannelStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Delete Me",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
getDialogs := func(userID int64) *tg.MessagesDialogs {
t.Helper()
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
box, ok := enc.(*tg.MessagesDialogsBox)
if !ok {
t.Fatalf("dialogs response = %T, want box", enc)
}
dialogs, ok := box.Dialogs.(*tg.MessagesDialogs)
if !ok {
t.Fatalf("dialogs = %T %+v, want messages.dialogs", box.Dialogs, box.Dialogs)
}
return dialogs
}
if got := getDialogs(owner.ID); len(got.Dialogs) != 1 || len(got.Chats) != 1 {
t.Fatalf("dialogs before delete = %+v, want one channel dialog", got)
}
deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
})
if err != nil {
t.Fatalf("delete channel: %v", err)
}
updates, ok := deleted.(*tg.Updates)
if !ok || len(updates.Updates) != 1 || len(updates.Chats) != 1 {
t.Fatalf("delete response = %T %+v, want updateChannel + channelForbidden", deleted, deleted)
}
if update, ok := updates.Updates[0].(*tg.UpdateChannel); !ok || update.ChannelID != channel.ID {
t.Fatalf("delete update = %#v, want updateChannel %d", updates.Updates[0], channel.ID)
}
forbidden, ok := updates.Chats[0].(*tg.ChannelForbidden)
if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || forbidden.Title != channel.Title || !forbidden.Megagroup {
t.Fatalf("delete chat = %#v, want channelForbidden tombstone", updates.Chats[0])
}
pushed := sessions.snapshot()
if pushed.messageType != proto.MessageFromServer || pushed.userID == 0 {
t.Fatalf("push snapshot = %+v, want server update to a channel member", pushed)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Chats) != 1 {
t.Fatalf("pushed update = %T %+v, want channelForbidden chat", pushed.message, pushed.message)
}
if pushedForbidden, ok := pushedUpdates.Chats[0].(*tg.ChannelForbidden); !ok || pushedForbidden.ID != channel.ID {
t.Fatalf("pushed chat = %#v, want channelForbidden %d", pushedUpdates.Chats[0], channel.ID)
}
if got := getDialogs(owner.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 {
t.Fatalf("owner dialogs after delete = %+v, want hidden deleted channel", got)
}
if got := getDialogs(friend.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 {
t.Fatalf("friend dialogs after delete = %+v, want hidden deleted channel", got)
}
}
func TestChannelGetMessagesReturnsSparseIDs(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002101", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002102", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Sparse RPC Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
var firstID, lastID int
for i := 0; i < 5; i++ {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "sparse-" + strconv.Itoa(i),
RandomID: int64(100 + i),
})
if err != nil {
t.Fatalf("send channel message %d: %v", i, err)
}
msg := updates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if i == 0 {
firstID = msg.ID
}
lastID = msg.ID
}
got, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []tg.InputMessageClass{
&tg.InputMessageID{ID: firstID},
&tg.InputMessageID{ID: lastID},
},
})
if err != nil {
t.Fatalf("get sparse channel messages: %v", err)
}
messages := got.(*tg.MessagesMessages).Messages
if len(messages) != 2 {
t.Fatalf("messages len = %d, want 2", len(messages))
}
first := messages[0].(*tg.Message)
last := messages[1].(*tg.Message)
if first.ID != firstID || first.Message != "sparse-0" || last.ID != lastID || last.Message != "sparse-4" {
t.Fatalf("sparse messages = %#v %#v, want first and last exact ids", first, last)
}
}
func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Clear Channel",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "clear me",
RandomID: 401,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID,
})
if err != nil {
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)
}
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])
}
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 := 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 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 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])
}
stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID - 1,
})
if err != nil {
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)
}
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)
}
}
func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002143", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 44, Phone: "15550002144", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Invalid Delete IDs",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
for _, maxID := range []int{-1, domain.MaxMessageBoxID + 1} {
if _, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{Channel: input, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("channels.deleteHistory max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err)
}
if _, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteHistoryRequest{Peer: peer, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("messages.deleteHistory channel max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err)
}
}
if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: []int{domain.MaxMessageBoxID + 1}}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("channels.deleteMessages huge id err = %v, want MESSAGE_ID_INVALID", err)
}
tooMany := make([]int, domain.MaxDeleteMessageIDs+1)
for i := range tooMany {
tooMany[i] = i + 1
}
if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: tooMany}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.deleteMessages too many ids err = %v, want LIMIT_INVALID", err)
}
}
func TestChannelsDeleteHistoryForEveryoneDrainsBatchesAndKeepsDialogVisible(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 45, Phone: "15550002145", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 46, Phone: "15550002146", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Drain Clear",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
total := domain.MaxDeleteHistoryBatch + 1
for i := 0; i < total; i++ {
if _, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channel.ID,
RandomID: int64(50_000 + i),
Message: "drain",
Date: 1_700_000_400 + i,
}); err != nil {
t.Fatalf("seed channel message %d: %v", i, err)
}
}
pushedBefore := len(sessions.pushedUserIDs())
clearReq := &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
}
clearReq.SetForEveryone(true)
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), clearReq)
if err != nil {
t.Fatalf("delete channel history for everyone: %v", err)
}
updates, ok := cleared.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("clear response = %T %+v, want final delete batch updates", cleared, cleared)
}
// 最后一批删除 invite 服务消息与剩余文本id=1 建群服务消息保留为
// 会话兜底 top message不进入删除批次。
deleteUpdate, ok := updates.Updates[0].(*tg.UpdateDeleteChannelMessages)
if !ok || deleteUpdate.ChannelID != channel.ID || deleteUpdate.PtsCount != len(deleteUpdate.Messages) || len(deleteUpdate.Messages) == 0 {
t.Fatalf("final batch update = %#v, want trailing delete batch sparing the creation service message", updates.Updates[0])
}
for _, id := range deleteUpdate.Messages {
if id <= 1 {
t.Fatalf("final batch deleted id %d, creation service message must be spared", id)
}
}
// 两批1000+1删除每批向 owner 和 friend 各推送一次。
if pushedAfter := len(sessions.pushedUserIDs()); pushedAfter-pushedBefore != 4 {
t.Fatalf("pushed fanout count = %d, want 4 (two batches to both members)", pushedAfter-pushedBefore)
}
history, err := channelStore.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channel.ID, Limit: 10})
if err != nil {
t.Fatalf("list history after clear: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != 1 || history.Messages[0].Action == nil {
t.Fatalf("history after clear = %+v, want only create service message", history.Messages)
}
getDialogs := func(userID int64) *tg.MessagesDialogs {
t.Helper()
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogsBox).Dialogs.(*tg.MessagesDialogs)
if !ok {
t.Fatalf("dialogs response = %T, want messages.dialogs", enc)
}
return dialogs
}
for _, viewer := range []int64{owner.ID, friend.ID} {
got := getDialogs(viewer)
if len(got.Dialogs) != 1 || len(got.Chats) != 1 {
t.Fatalf("dialogs for %d after clear = %+v, want channel dialog kept visible", viewer, got)
}
dialog := got.Dialogs[0].(*tg.Dialog)
if dialog.TopMessage != 1 {
t.Fatalf("dialog top for %d = %d, want create service message 1", viewer, dialog.TopMessage)
}
if len(got.Messages) != 1 {
t.Fatalf("dialog top messages for %d = %+v, want create service message attached", viewer, got.Messages)
}
if _, ok := got.Messages[0].(*tg.MessageService); !ok {
t.Fatalf("dialog top message for %d = %T, want messageService", viewer, got.Messages[0])
}
}
againReq := &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
}
againReq.SetForEveryone(true)
again, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), againReq)
if err != nil {
t.Fatalf("repeat delete channel history: %v", err)
}
if repeat, ok := again.(*tg.Updates); !ok || len(repeat.Updates) != 0 {
t.Fatalf("repeat clear response = %T %+v, want empty idempotent updates", again, again)
}
}
func TestBroadcastChannelPostRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Member"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "RPC Broadcast",
About: "news",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
if !channel.Broadcast || channel.Megagroup {
t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", channel.Broadcast, channel.Megagroup)
}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}},
}); err != nil {
t.Fatalf("invite member to broadcast: %v", err)
}
posted, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "broadcast post",
RandomID: 7071,
})
if err != nil {
t.Fatalf("owner send broadcast post: %v", err)
}
postUpdate := posted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
postMsg := postUpdate.Message.(*tg.Message)
if !postMsg.Post || postMsg.FromID != nil || postMsg.Message != "broadcast post" {
t.Fatalf("broadcast post message = %#v, want post without from_id", postMsg)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "member post",
RandomID: 7072,
}); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("member broadcast send err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
AdminRights: tg.ChatAdminRights{ChangeInfo: true},
}); err != nil {
t.Fatalf("promote member without post_messages: %v", err)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "admin without post right",
RandomID: 7073,
}); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("admin without post right send err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
AdminRights: tg.ChatAdminRights{PostMessages: true},
}); err != nil {
t.Fatalf("grant member post_messages: %v", err)
}
adminPosted, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "admin post",
RandomID: 7074,
})
if err != nil {
t.Fatalf("admin send broadcast post: %v", err)
}
adminPostMsg := adminPosted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if !adminPostMsg.Post || adminPostMsg.FromID != nil || adminPostMsg.Message != "admin post" {
t.Fatalf("admin broadcast post message = %#v, want post without from_id", adminPostMsg)
}
diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, member.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: 1,
Limit: 10,
})
if err != nil {
t.Fatalf("member channel difference: %v", err)
}
fullDiff := diff.(*tg.UpdatesChannelDifference)
foundPost := false
for _, msg := range fullDiff.NewMessages {
if item, ok := msg.(*tg.Message); ok && item.Message == "broadcast post" {
foundPost = item.Post && item.FromID == nil
}
}
if !foundPost {
t.Fatalf("diff new messages = %+v, want broadcast post without from_id", fullDiff.NewMessages)
}
}
func TestChannelSendMessageRPCResolvesReplyHeader(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002031", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002032", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "RPC Reply Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
rootUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "root",
RandomID: 3001,
})
if err != nil {
t.Fatalf("send root: %v", err)
}
rootMsg := rootUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
replyTo := &tg.InputReplyToMessage{ReplyToMsgID: rootMsg.ID}
replyTo.SetQuoteText("root")
replyTo.SetQuoteOffset(0)
replyReq := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "reply",
RandomID: 3002,
}
replyReq.SetReplyTo(replyTo)
replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), replyReq)
if err != nil {
t.Fatalf("send reply: %v", err)
}
replyMsg := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
header, ok := replyMsg.ReplyTo.(*tg.MessageReplyHeader)
if !ok {
t.Fatalf("reply header = %#v, want messageReplyHeader", replyMsg.ReplyTo)
}
topID, topOK := header.GetReplyToTopID()
quoteText, quoteOK := header.GetQuoteText()
if header.ReplyToMsgID != rootMsg.ID || !topOK || topID != rootMsg.ID || !quoteOK || quoteText != "root" {
t.Fatalf("reply header = %#v, want msg/top %d and quote", header, rootMsg.ID)
}
badReq := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "bad",
RandomID: 3003,
}
badReq.SetReplyTo(&tg.InputReplyToMessage{ReplyToMsgID: 999})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), badReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") {
t.Fatalf("bad reply err = %v, want REPLY_MESSAGE_ID_INVALID", err)
}
}
func TestChannelBoostUnlocksDefaultSendRestrictionRPC(t *testing.T) {
f := newRPCChannelFixture(t)
r := f.router
owner := f.user(9101, "15550009101", "Owner")
member := f.user(9102, "15550009102", "Member")
channel := f.createLegacyMegagroup(owner, "Boost Gate", member)
ownerCtx := f.userCtx(owner)
memberCtx := f.userCtx(member)
if _, err := r.onMessagesEditChatDefaultBannedRights(ownerCtx, &tg.MessagesEditChatDefaultBannedRightsRequest{
Peer: inputPeerChannel(channel),
BannedRights: tg.ChatBannedRights{
SendMessages: true,
},
}); err != nil {
t.Fatalf("edit default banned rights: %v", err)
}
if _, err := r.onMessagesSendMessage(memberCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(channel),
Message: "blocked",
RandomID: 9102001,
}); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("member send before boost err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
if _, err := r.onChannelsSetBoostsToUnblockRestrictions(ownerCtx, &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{
Channel: inputChannel(channel),
Boosts: 1,
}); err != nil {
t.Fatalf("set boosts to unblock: %v", err)
}
if _, err := f.users.SetPremiumUntil(f.ctx, member.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
t.Fatalf("grant member premium: %v", err)
}
applyReq := &tg.PremiumApplyBoostRequest{Peer: inputPeerChannel(channel)}
applyReq.SetSlots([]int{domain.DefaultPremiumBoostSlotID})
if _, err := r.onPremiumApplyBoost(memberCtx, applyReq); err != nil {
t.Fatalf("apply member boost: %v", err)
}
full, err := r.onChannelsGetFullChannel(memberCtx, inputChannel(channel))
if err != nil {
t.Fatalf("get full channel after boost: %v", err)
}
channelFull := full.FullChat.(*tg.ChannelFull)
if boosts, ok := channelFull.GetBoostsApplied(); !ok || boosts != 1 {
t.Fatalf("full channel boosts_applied = %d ok %v, want 1", boosts, ok)
}
if threshold, ok := channelFull.GetBoostsUnrestrict(); !ok || threshold != 1 {
t.Fatalf("full channel boosts_unrestrict = %d ok %v, want 1", threshold, ok)
}
sent, err := r.onMessagesSendMessage(memberCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(channel),
Message: "boosted ok",
RandomID: 9102002,
})
if err != nil {
t.Fatalf("member send after boost: %v", err)
}
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if applied, ok := msg.GetFromBoostsApplied(); !ok || applied != 1 {
t.Fatalf("message from_boosts_applied = %d ok %v, want 1", applied, ok)
}
}
func TestChannelSendAsCurrentChannelRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002111", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002112", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Send As Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sendAs := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "as channel",
RandomID: 501,
SendAs: sendAs,
})
if err != nil {
t.Fatalf("send as current channel: %v", err)
}
sentUpdates := sent.(*tg.Updates)
sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID {
t.Fatalf("send_as message from = %#v, want channel %d", sentMsg.FromID, channel.ID)
}
history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []tg.InputMessageClass{&tg.InputMessageID{ID: sentMsg.ID}},
})
if err != nil {
t.Fatalf("get send_as history: %v", err)
}
historyMsg := history.(*tg.MessagesMessages).Messages[0].(*tg.Message)
if from, ok := historyMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID {
t.Fatalf("history send_as from = %#v, want channel %d", historyMsg.FromID, channel.ID)
}
forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{sentMsg.ID},
RandomID: []int64{502},
SendAs: sendAs,
})
if err != nil {
t.Fatalf("forward as current channel: %v", err)
}
forwardMsg := forwarded.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := forwardMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID {
t.Fatalf("forward send_as from = %#v, want channel %d", forwardMsg.FromID, channel.ID)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "bad private send_as",
RandomID: 503,
SendAs: sendAs,
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("private send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "bad member send_as",
RandomID: 504,
SendAs: sendAs,
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("member send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ToPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
ID: []int{sentMsg.ID},
RandomID: []int64{505},
SendAs: sendAs,
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("private forward send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
}
func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91001, Phone: "15550091001", FirstName: "Owner"})
viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
}, zaptest.NewLogger(t), clock.System)
public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Public Search",
Broadcast: true,
Date: 1700010000,
})
if err != nil {
t.Fatalf("create public channel: %v", err)
}
if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID,
ChannelID: public.Channel.ID,
Username: "public_search_posts",
}); err != nil {
t.Fatalf("publish channel username: %v", err)
}
private, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Private Search",
Broadcast: true,
Date: 1700010001,
})
if err != nil {
t.Fatalf("create private channel: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 101,
Message: "launch alpha #ops",
Date: 1700010010,
}); err != nil {
t.Fatalf("send public first: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 102,
Message: "launch beta #ops",
Date: 1700010020,
}); err != nil {
t.Fatalf("send public second: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: private.Channel.ID,
RandomID: 103,
Message: "launch private #ops",
Date: 1700010030,
}); err != nil {
t.Fatalf("send private: %v", err)
}
req := &tg.ChannelsSearchPostsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 1,
}
req.SetQuery("launch")
got, err := r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), req)
if err != nil {
t.Fatalf("channels.searchPosts first page: %v", err)
}
slice, ok := got.(*tg.MessagesMessagesSlice)
if !ok {
t.Fatalf("first page = %T %+v, want messagesSlice", got, got)
}
if slice.Count <= len(slice.Messages) {
t.Fatalf("first page count = %d messages=%d, want more page", slice.Count, len(slice.Messages))
}
if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700010020 {
t.Fatalf("first page next_rate = %d ok %v, want newest message date", nextRate, ok)
}
if flood, ok := slice.GetSearchFlood(); !ok || !flood.QueryIsFree {
t.Fatalf("first page search_flood = %+v ok %v, want free flood state", flood, ok)
}
messages, chats, users := searchMessagesPayload(t, got)
if len(messages) != 1 || len(chats) != 1 || len(users) != 1 {
t.Fatalf("first page payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users))
}
first := messages[0].(*tg.Message)
if first.Message != "launch beta #ops" {
t.Fatalf("first result message = %q, want newest public hit", first.Message)
}
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)
}
page2 := &tg.ChannelsSearchPostsRequest{
OffsetRate: slice.NextRate,
OffsetPeer: &tg.InputPeerChannel{
ChannelID: public.Channel.ID,
AccessHash: public.Channel.AccessHash,
},
OffsetID: first.ID,
Limit: 10,
}
page2.SetQuery("launch")
got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), page2)
if err != nil {
t.Fatalf("channels.searchPosts second page: %v", err)
}
messages, chats, _ = searchMessagesPayload(t, got)
if len(messages) != 1 || len(chats) != 1 {
t.Fatalf("second page payload messages=%d chats=%d, want only older public hit", len(messages), len(chats))
}
if msg := messages[0].(*tg.Message); msg.Message != "launch alpha #ops" {
t.Fatalf("second result message = %q, want older public hit", msg.Message)
}
hashtagReq := &tg.ChannelsSearchPostsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 10,
}
hashtagReq.SetHashtag("ops")
got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), hashtagReq)
if err != nil {
t.Fatalf("channels.searchPosts hashtag: %v", err)
}
messages, _, _ = searchMessagesPayload(t, got)
if len(messages) != 2 {
t.Fatalf("hashtag results = %d, want two public hits only", len(messages))
}
for _, item := range messages {
if strings.Contains(item.(*tg.Message).Message, "private") {
t.Fatalf("hashtag leaked private message: %#v", item)
}
}
}
func TestChannelsSearchPostsValidatesStubBounds(t *testing.T) {
const userID = int64(1000000001)
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
valid := &tg.ChannelsSearchPostsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
valid.SetQuery("launch")
got, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), valid)
if err != nil {
t.Fatalf("channels.searchPosts valid stub: %v", err)
}
if page, ok := got.(*tg.MessagesMessages); !ok || len(page.Messages) != 0 || len(page.Chats) != 0 || len(page.Users) != 0 {
t.Fatalf("channels.searchPosts = %T %+v, want empty messages", got, got)
}
both := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}
both.SetQuery("launch")
both.SetHashtag("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), both); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") {
t.Fatalf("channels.searchPosts query+hashtag err = %v, want SEARCH_QUERY_EMPTY", err)
}
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") {
t.Fatalf("channels.searchPosts empty query err = %v, want SEARCH_QUERY_EMPTY", err)
}
huge := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}
huge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1))
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), huge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.searchPosts huge query err = %v, want LIMIT_INVALID", err)
}
badOffset := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, OffsetID: domain.MaxMessageBoxID + 1}
badOffset.SetHashtag("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badOffset); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("channels.searchPosts huge offset_id err = %v, want MESSAGE_ID_INVALID", err)
}
badStars := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, AllowPaidStars: -1}
badStars.SetQuery("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badStars); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.searchPosts negative stars err = %v, want LIMIT_INVALID", err)
}
badPeer := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerUser{UserID: 42}, Limit: 1}
badPeer.SetQuery("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badPeer); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") {
t.Fatalf("channels.searchPosts user offset peer err = %v, want PEER_ID_INVALID", err)
}
floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{}
floodReq.SetQuery("launch")
flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodReq)
if err != nil {
t.Fatalf("channels.checkSearchPostsFlood: %v", err)
}
if !flood.QueryIsFree || flood.Remains <= 0 || flood.TotalDaily <= 0 {
t.Fatalf("channels.checkSearchPostsFlood = %+v, want free quota", flood)
}
if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), &tg.ChannelsCheckSearchPostsFloodRequest{}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") {
t.Fatalf("channels.checkSearchPostsFlood empty query err = %v, want SEARCH_QUERY_EMPTY", err)
}
floodHuge := &tg.ChannelsCheckSearchPostsFloodRequest{}
floodHuge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1))
if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodHuge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.checkSearchPostsFlood huge query err = %v, want LIMIT_INVALID", err)
}
}

View file

@ -0,0 +1,229 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestChannelMultiPinAndroidOpenAndJump 模拟 DrKLO Android 超级群多置顶消费链路:
// 打开聊天 → messages.search(filterPinned, limit=40, offset_id=0) 全量拉置顶列表;
// 点置顶栏跳最旧 pin → messages.getHistory(offset_id=pin, add_offset=-count/2, limit=count)
// AROUND 加载,响应必须包含锚点消息本身,否则客户端弹 MessageNotFound 放弃跳转;
// 本地缺对象 → channels.getMessages 精确补拉messageEmpty 会被客户端丢弃。
func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550001161", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550001162", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "MultiPin Android",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700001000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
const total = 30
ids := make([]int, 0, total)
for i := 0; i < total; i++ {
sent, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: channelID,
RandomID: int64(961000 + i),
Message: "msg",
Date: 1700001001 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
ids = append(ids, sent.Message.ID)
}
// 三条置顶早期、中间、最新Android 置顶栏循环跳转需要全部三条都可跳)。
pins := []int{ids[4], ids[14], ids[27]}
for _, id := range pins {
if _, err := channelSvc.UpdatePinnedMessage(ctx, owner.ID, domain.UpdateChannelPinnedMessageRequest{
ChannelID: channelID,
MessageID: id,
Pinned: true,
Date: 1700001100,
}); err != nil {
t.Fatalf("pin %d: %v", id, err)
}
}
memberView, err := channelSvc.GetChannel(ctx, member.ID, channelID)
if err != nil {
t.Fatalf("member get channel: %v", err)
}
peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash}
dispatch := func(req bin.Encoder) bin.Encoder {
t.Helper()
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode request: %v", err)
}
enc, err := r.Dispatch(WithUserID(androidClientContext(), member.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
return enc
}
// ① 打开聊天MediaDataController.loadPinnedMessages → messages.search filterPinned。
searchEnc := dispatch(&tg.MessagesSearchRequest{
Peer: peer,
Q: "",
Filter: &tg.InputMessagesFilterPinned{},
Limit: 40,
})
if box, ok := searchEnc.(*tg.MessagesMessagesBox); ok {
searchEnc = box.Messages
}
channelMessages, ok := searchEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("pinned search response = %T, want messages.channelMessages", searchEnc)
}
if len(channelMessages.Messages) != len(pins) {
t.Fatalf("pinned search messages = %d, want %d", len(channelMessages.Messages), len(pins))
}
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]}
for i, raw := range channelMessages.Messages {
msg, ok := raw.(*tg.Message)
if !ok {
// TL_messageService / TL_messageEmpty 会被 Android loadPinnedMessages 直接跳过。
t.Fatalf("pinned search message[%d] = %T, want *tg.Message", i, raw)
}
if msg.ID != wantDesc[i] {
t.Fatalf("pinned search order[%d] = %d, want %d (id desc)", i, msg.ID, wantDesc[i])
}
if !msg.Pinned {
t.Fatalf("pinned search message %d lacks pinned flag", msg.ID)
}
}
// ② 点置顶栏跳最旧 pinscrollToMessageId → getHistory AROUND手机 count=20
const aroundCount = 20
histEnc := dispatch(&tg.MessagesGetHistoryRequest{
Peer: peer,
OffsetID: pins[0],
AddOffset: -aroundCount / 2,
Limit: aroundCount,
})
histMessages, _, _ := searchMessagesPayload(t, histEnc)
if len(histMessages) == 0 || len(histMessages) > aroundCount {
t.Fatalf("around history size = %d, want 1..%d (超出 count 时 Android 会丢最新一条)", len(histMessages), aroundCount)
}
anchorFound := false
lastID := int(^uint(0) >> 1)
for _, raw := range histMessages {
if _, isEmpty := raw.(*tg.MessageEmpty); isEmpty {
t.Fatalf("around history contains messageEmpty")
}
id := raw.GetID()
if id >= lastID {
t.Fatalf("around history not id-desc: %d then %d", lastID, id)
}
lastID = id
if id == pins[0] {
anchorFound = true
}
}
if !anchorFound {
// ChatActivity postponedScroll 在响应缺锚点时直接 MessageNotFound 放弃跳转。
t.Fatalf("around history lacks anchor %d: jump shows MessageNotFound on Android", pins[0])
}
// ③ 本地缺对象补拉MessagesStorage.loadChatInfo → channels.getMessages。
// DrKLO 发的是 pre-InputMessage 构造器 #93d7b347id:Vector<int>
// 该请求 500 会让客户端把这批 pin 按「已取消置顶」从本地缓存删除。
var legacy bin.Buffer
legacy.PutID(0x93d7b347)
if err := (&tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash}).Encode(&legacy); err != nil {
t.Fatalf("encode legacy input channel: %v", err)
}
legacy.PutVectorHeader(len(pins))
for _, id := range pins {
legacy.PutInt(id)
}
getEnc, err := r.Dispatch(WithUserID(androidClientContext(), member.ID), [8]byte{}, 0, &legacy)
if err != nil {
t.Fatalf("dispatch legacy channels.getMessages#93d7b347: %v", err)
}
getMessages, _, _ := searchMessagesPayload(t, getEnc)
if len(getMessages) != len(pins) {
t.Fatalf("legacy channels.getMessages size = %d, want %d", len(getMessages), len(pins))
}
for i, raw := range getMessages {
msg, ok := raw.(*tg.Message)
if !ok {
t.Fatalf("legacy channels.getMessages[%d] = %T, want *tg.Message (messageEmpty 会被客户端丢弃)", i, raw)
}
if !msg.Pinned {
t.Fatalf("legacy channels.getMessages message %d lacks pinned flag", msg.ID)
}
}
// 新构造器TDesktop 路径)必须与 legacy 返回一致的消息集合。
getIDs := make([]tg.InputMessageClass, 0, len(pins))
for _, id := range pins {
getIDs = append(getIDs, &tg.InputMessageID{ID: id})
}
modernEnc := dispatch(&tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash},
ID: getIDs,
})
modernMessages, _, _ := searchMessagesPayload(t, modernEnc)
if len(modernMessages) != len(getMessages) {
t.Fatalf("modern channels.getMessages size = %d, want %d (legacy/modern must match)", len(modernMessages), len(getMessages))
}
for i := range modernMessages {
if modernMessages[i].GetID() != getMessages[i].GetID() {
t.Fatalf("modern/legacy mismatch at %d: %d != %d", i, modernMessages[i].GetID(), getMessages[i].GetID())
}
}
// ④ chatFull 降级缓存pinned_msg_id 必须是最新置顶Android 以它判断是否重拉列表)。
fullEnc := dispatch(&tg.ChannelsGetFullChannelRequest{
Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash},
})
full, ok := fullEnc.(*tg.MessagesChatFull)
if !ok {
t.Fatalf("getFullChannel response = %T, want messages.chatFull", fullEnc)
}
channelFull, ok := full.FullChat.(*tg.ChannelFull)
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])
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,199 @@
package rpc
import (
"context"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
ctx := context.Background()
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"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
dialogService := appdialogs.NewService(memory.NewDialogStore(), channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Dialogs: dialogService,
}, zaptest.NewLogger(t), clock.System)
public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Public Preview RPC",
Broadcast: true,
Date: 1700010100,
})
if err != nil {
t.Fatalf("create public channel: %v", err)
}
if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID,
ChannelID: public.Channel.ID,
Username: "public_preview_rpc",
}); err != nil {
t.Fatalf("publish channel username: %v", err)
}
sent, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 201,
Message: "public preview rpc post",
Date: 1700010110,
})
if err != nil {
t.Fatalf("send public post: %v", err)
}
input := &tg.InputChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash}
peer := &tg.InputPeerChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, viewer.ID), input)
if err != nil {
t.Fatalf("non-member getFullChannel public preview: %v", err)
}
if len(full.Chats) != 1 {
t.Fatalf("full chats = %d, want one channel", len(full.Chats))
}
chat, ok := full.Chats[0].(*tg.Channel)
if !ok || !chat.Left || chat.ID != public.Channel.ID {
t.Fatalf("full channel chat = %T %+v, want left public channel", full.Chats[0], full.Chats[0])
}
channelFull, ok := full.FullChat.(*tg.ChannelFull)
if !ok || channelFull.ID != public.Channel.ID || channelFull.UnreadCount != 0 {
t.Fatalf("full chat = %T %+v, want channel full without unread", full.FullChat, full.FullChat)
}
chats, err := r.onChannelsGetChannels(WithUserID(ctx, viewer.ID), []tg.InputChannelClass{input})
if err != nil {
t.Fatalf("non-member getChannels public preview: %v", err)
}
if len(chats.(*tg.MessagesChats).Chats) != 1 {
t.Fatalf("getChannels chats = %d, want one public preview channel", len(chats.(*tg.MessagesChats).Chats))
}
listed, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel)
if !ok || !listed.Left || listed.ID != public.Channel.ID {
t.Fatalf("getChannels chat = %T %+v, want left public channel", chats.(*tg.MessagesChats).Chats[0], chats.(*tg.MessagesChats).Chats[0])
}
sendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, viewer.ID), &tg.ChannelsGetSendAsRequest{Peer: peer})
if err != nil {
t.Fatalf("non-member getSendAs public preview: %v", err)
}
if len(sendAs.Peers) != 1 {
t.Fatalf("sendAs peers = %+v, want only current user peer", sendAs.Peers)
}
if len(sendAs.Chats) != 1 {
t.Fatalf("sendAs chats = %d, want public channel chat", len(sendAs.Chats))
}
historyReq := &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 10}
var in bin.Buffer
if err := historyReq.Encode(&in); err != nil {
t.Fatalf("encode getHistory: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch getHistory public preview: %v", err)
}
box, ok := enc.(*tg.MessagesMessagesBox)
if !ok {
t.Fatalf("getHistory response = %T, want boxed messages", enc)
}
history, ok := box.Messages.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("boxed getHistory = %T, want channel messages", box.Messages)
}
foundPost := false
for _, item := range history.Messages {
if msg, ok := item.(*tg.Message); ok && msg.Message == "public preview rpc post" {
foundPost = true
}
}
if !foundPost {
t.Fatalf("history messages = %+v, want public preview post", history.Messages)
}
if len(history.Chats) != 1 {
t.Fatalf("history chats = %d, want public channel chat", len(history.Chats))
}
historyChat, ok := history.Chats[0].(*tg.Channel)
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])
}
diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewer.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: input,
Pts: public.Event.Pts,
Limit: 10,
})
if err != nil {
t.Fatalf("non-member getChannelDifference public preview: %v", err)
}
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 at current pts", diff, diff)
}
diffMsg, ok := fullDiff.NewMessages[0].(*tg.Message)
if !ok || diffMsg.Message != "public preview rpc post" {
t.Fatalf("channel difference message = %T %+v, want public preview rpc post", fullDiff.NewMessages[0], fullDiff.NewMessages[0])
}
if len(fullDiff.Chats) != 1 {
t.Fatalf("channel difference chats = %d, want public channel chat", len(fullDiff.Chats))
}
diffChat, ok := fullDiff.Chats[0].(*tg.Channel)
if !ok || !diffChat.Left || diffChat.ID != public.Channel.ID {
t.Fatalf("channel difference chat = %T %+v, want left public channel", fullDiff.Chats[0], fullDiff.Chats[0])
}
domainPeers, err := r.dialogPeersFromInput(WithUserID(ctx, viewer.ID), viewer.ID, []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}})
if err != nil {
t.Fatalf("dialog peer conversion public preview: %v", err)
}
if len(domainPeers) != 1 || domainPeers[0].Type != domain.PeerTypeChannel || domainPeers[0].ID != public.Channel.ID {
t.Fatalf("domain peers = %+v, want public channel peer", domainPeers)
}
directPeerDialogs, err := dialogService.GetPeerDialogs(ctx, viewer.ID, domainPeers)
if err != nil {
t.Fatalf("dialog service public preview: %v", err)
}
if len(directPeerDialogs.Dialogs) != 1 || len(directPeerDialogs.ChannelMessages) != 1 || len(directPeerDialogs.Channels) != 1 {
t.Fatalf("direct peer dialogs = %+v, want one dialog/message/channel", directPeerDialogs)
}
peerDialogsReq := &tg.MessagesGetPeerDialogsRequest{
Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}},
}
var peerDialogsIn bin.Buffer
if err := peerDialogsReq.Encode(&peerDialogsIn); err != nil {
t.Fatalf("encode getPeerDialogs: %v", err)
}
peerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &peerDialogsIn)
if err != nil {
t.Fatalf("dispatch getPeerDialogs public preview: %v", err)
}
peerDialogs, ok := peerDialogsEnc.(*tg.MessagesPeerDialogs)
if !ok {
t.Fatalf("getPeerDialogs response = %T, want peer dialogs", peerDialogsEnc)
}
if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Messages) != 1 || len(peerDialogs.Chats) != 1 {
t.Fatalf("peer dialogs = %+v, want one dialog/message/channel", peerDialogs)
}
tgDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog)
if !ok || tgDialog.TopMessage <= 0 || tgDialog.UnreadCount != 0 {
t.Fatalf("peer dialog = %T %+v, want read-only public preview dialog", peerDialogs.Dialogs[0], peerDialogs.Dialogs[0])
}
tgMessage, ok := peerDialogs.Messages[0].(*tg.Message)
if !ok || tgMessage.Message != "public preview rpc post" {
t.Fatalf("peer dialog message = %T %+v, want public preview rpc post", peerDialogs.Messages[0], peerDialogs.Messages[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])
}
}

View file

@ -0,0 +1,130 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onChannelsReadMessageContents(ctx context.Context, req *tg.ChannelsReadMessageContentsRequest) (bool, error) {
if r.deps.Channels == nil {
return false, notImplementedErr()
}
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return false, err
}
read, err := r.deps.Channels.ReadMessageContents(ctx, userID, domain.ReadChannelMessageContentsRequest{
UserID: userID,
ChannelID: channelID,
IDs: req.ID,
})
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
return false, messageIDInvalidErr()
}
return false, channelInvalidErr(err)
}
if ids := readChannelMessageContentIDs(read.Messages); len(ids) > 0 {
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateChannelReadMessagesContents{
ChannelID: read.Channel.ID,
Messages: ids,
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{tgChannelChatMin(userID, read.Channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
}
if len(read.ClearedUnreadReactionMessageIDs) > 0 {
r.pushUserUpdates(ctx, userID, r.channelMessagesReactionsUpdates(ctx, userID, domain.ChannelMessageReactionsResult{
Channel: read.Channel,
Messages: read.Messages,
}, read.ClearedUnreadReactionMessageIDs))
}
return true, nil
}
func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsReadHistoryRequest) (bool, error) {
if r.deps.Channels == nil {
return true, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return false, err
}
read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{
UserID: userID,
ChannelID: channelID,
MaxID: req.MaxID,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return false, channelInvalidErr(err)
}
if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil {
return false, err
}
r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates)
r.advanceForumGeneralReadAfterChannelRead(ctx, userID, read)
return true, nil
}
func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest) (domain.ChannelReactionPolicy, error) {
if req == nil || req.AvailableReactions == nil {
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
}
if req.ReactionsLimit < 0 || req.ReactionsLimit > domain.MaxChannelReactionsLimit {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
policy := domain.ChannelReactionPolicy{
Limit: req.ReactionsLimit,
PaidEnabled: req.PaidEnabled,
}
switch reactions := req.AvailableReactions.(type) {
case *tg.ChatReactionsNone:
policy.Type = domain.ChannelReactionPolicyNone
case *tg.ChatReactionsAll:
policy.Type = domain.ChannelReactionPolicyAll
policy.AllowCustom = reactions.AllowCustom
case *tg.ChatReactionsSome:
if len(reactions.Reactions) > domain.MaxChannelReactionTypes {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
policy.Type = domain.ChannelReactionPolicySome
seen := make(map[string]struct{}, len(reactions.Reactions))
for _, reaction := range reactions.Reactions {
parsed, err := domainMessageReactionFromTL(reaction)
if err != nil {
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
}
key := parsed.Key()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
switch parsed.Type {
case domain.MessageReactionEmoji:
policy.Emoticons = append(policy.Emoticons, parsed.Emoticon)
case domain.MessageReactionCustomEmoji:
policy.CustomEmojiIDs = append(policy.CustomEmojiIDs, parsed.DocumentID)
}
}
default:
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
}
return policy, nil
}

View file

@ -0,0 +1,101 @@
package rpc
import (
"github.com/gotd/td/tg"
)
// registerChannels 注册超级群/频道相关 RPC。messages.createChat 在这里注册,
// 因为 telesrv 将普通群创建直接实现为 megagroup。
func (r *Router) registerChannels(d *tg.ServerDispatcher) {
d.OnMessagesCreateChat(r.onMessagesCreateChat)
d.OnMessagesMigrateChat(r.onMessagesMigrateChat)
d.OnMessagesGetChats(r.onMessagesGetChats)
d.OnMessagesGetFullChat(r.onMessagesGetFullChat)
d.OnMessagesAddChatUser(r.onMessagesAddChatUser)
d.OnMessagesDeleteChatUser(r.onMessagesDeleteChatUser)
d.OnMessagesEditChatTitle(r.onMessagesEditChatTitle)
d.OnMessagesEditChatPhoto(r.onMessagesEditChatPhoto)
d.OnMessagesEditChatAdmin(r.onMessagesEditChatAdmin)
d.OnMessagesEditChatAbout(r.onMessagesEditChatAbout)
d.OnMessagesEditChatDefaultBannedRights(r.onMessagesEditChatDefaultBannedRights)
d.OnMessagesEditChatCreator(r.onMessagesEditChatCreator)
d.OnMessagesGetFutureChatCreatorAfterLeave(r.onMessagesGetFutureChatCreatorAfterLeave)
d.OnMessagesEditChatParticipantRank(r.onMessagesEditChatParticipantRank)
d.OnMessagesSetChatTheme(r.onMessagesSetChatTheme)
d.OnMessagesSetChatWallPaper(r.onMessagesSetChatWallPaper)
d.OnMessagesToggleNoForwards(r.onMessagesToggleNoForwards)
d.OnMessagesSetChatAvailableReactions(r.onMessagesSetChatAvailableReactions)
d.OnChannelsCreateChannel(r.onChannelsCreateChannel)
d.OnChannelsGetChannels(r.onChannelsGetChannels)
d.OnChannelsGetFullChannel(r.onChannelsGetFullChannel)
d.OnChannelsGetParticipants(r.onChannelsGetParticipants)
d.OnChannelsGetParticipant(r.onChannelsGetParticipant)
d.OnChannelsGetSendAs(r.onChannelsGetSendAs)
d.OnChannelsCheckUsername(r.onChannelsCheckUsername)
d.OnChannelsUpdateUsername(r.onChannelsUpdateUsername)
d.OnChannelsGetAdminedPublicChannels(r.onChannelsGetAdminedPublicChannels)
d.OnChannelsExportMessageLink(r.onChannelsExportMessageLink)
d.OnChannelsToggleSignatures(r.onChannelsToggleSignatures)
d.OnChannelsTogglePreHistoryHidden(r.onChannelsTogglePreHistoryHidden)
d.OnChannelsToggleSlowMode(r.onChannelsToggleSlowMode)
d.OnChannelsSetStickers(r.onChannelsSetStickers)
d.OnChannelsSetEmojiStickers(r.onChannelsSetEmojiStickers)
d.OnChannelsReorderUsernames(r.onChannelsReorderUsernames)
d.OnChannelsToggleUsername(r.onChannelsToggleUsername)
d.OnChannelsDeactivateAllUsernames(r.onChannelsDeactivateAllUsernames)
d.OnChannelsUpdateColor(r.onChannelsUpdateColor)
d.OnChannelsUpdateEmojiStatus(r.onChannelsUpdateEmojiStatus)
d.OnChannelsReadMessageContents(r.onChannelsReadMessageContents)
d.OnChannelsReportSpam(r.onChannelsReportSpam)
d.OnChannelsGetLeftChannels(r.onChannelsGetLeftChannels)
d.OnChannelsGetInactiveChannels(r.onChannelsGetInactiveChannels)
d.OnChannelsGetGroupsForDiscussion(r.onChannelsGetGroupsForDiscussion)
d.OnChannelsSetDiscussionGroup(r.onChannelsSetDiscussionGroup)
d.OnChannelsEditLocation(r.onChannelsEditLocation)
d.OnChannelsConvertToGigagroup(r.onChannelsConvertToGigagroup)
d.OnChannelsDeleteParticipantHistory(r.onChannelsDeleteParticipantHistory)
d.OnChannelsToggleJoinToSend(r.onChannelsToggleJoinToSend)
d.OnChannelsToggleJoinRequest(r.onChannelsToggleJoinRequest)
d.OnChannelsToggleForum(r.onChannelsToggleForum)
d.OnChannelsToggleAntiSpam(r.onChannelsToggleAntiSpam)
d.OnChannelsReportAntiSpamFalsePositive(r.onChannelsReportAntiSpamFalsePositive)
d.OnChannelsToggleParticipantsHidden(r.onChannelsToggleParticipantsHidden)
d.OnChannelsToggleViewForumAsMessages(r.onChannelsToggleViewForumAsMessages)
d.OnChannelsGetChannelRecommendations(r.onChannelsGetChannelRecommendations)
d.OnChannelsSetBoostsToUnblockRestrictions(r.onChannelsSetBoostsToUnblockRestrictions)
d.OnChannelsRestrictSponsoredMessages(r.onChannelsRestrictSponsoredMessages)
d.OnChannelsSearchPosts(r.onChannelsSearchPosts)
d.OnChannelsUpdatePaidMessagesPrice(r.onChannelsUpdatePaidMessagesPrice)
d.OnChannelsToggleAutotranslation(r.onChannelsToggleAutotranslation)
d.OnChannelsGetMessageAuthor(r.onChannelsGetMessageAuthor)
d.OnChannelsCheckSearchPostsFlood(r.onChannelsCheckSearchPostsFlood)
d.OnChannelsSetMainProfileTab(r.onChannelsSetMainProfileTab)
d.OnChannelsInviteToChannel(r.onChannelsInviteToChannel)
d.OnChannelsJoinChannel(r.onChannelsJoinChannel)
d.OnChannelsLeaveChannel(r.onChannelsLeaveChannel)
d.OnChannelsEditAdmin(r.onChannelsEditAdmin)
d.OnChannelsEditBanned(r.onChannelsEditBanned)
d.OnChannelsEditTitle(r.onChannelsEditTitle)
d.OnChannelsEditPhoto(r.onChannelsEditPhoto)
d.OnChannelsDeleteChannel(r.onChannelsDeleteChannel)
d.OnChannelsGetAdminLog(r.onChannelsGetAdminLog)
d.OnChannelsReadHistory(r.onChannelsReadHistory)
d.OnChannelsGetMessages(r.onChannelsGetMessages)
d.OnChannelsDeleteMessages(r.onChannelsDeleteMessages)
d.OnChannelsDeleteHistory(r.onChannelsDeleteHistory)
d.OnMessagesUpdatePinnedMessage(r.onMessagesUpdatePinnedMessage)
d.OnMessagesUnpinAllMessages(r.onMessagesUnpinAllMessages)
d.OnMessagesExportChatInvite(r.onMessagesExportChatInvite)
d.OnMessagesCheckChatInvite(r.onMessagesCheckChatInvite)
d.OnMessagesImportChatInvite(r.onMessagesImportChatInvite)
d.OnMessagesGetExportedChatInvites(r.onMessagesGetExportedChatInvites)
d.OnMessagesGetExportedChatInvite(r.onMessagesGetExportedChatInvite)
d.OnMessagesEditExportedChatInvite(r.onMessagesEditExportedChatInvite)
d.OnMessagesDeleteRevokedExportedChatInvites(r.onMessagesDeleteRevokedExportedChatInvites)
d.OnMessagesDeleteExportedChatInvite(r.onMessagesDeleteExportedChatInvite)
d.OnMessagesGetAdminsWithInvites(r.onMessagesGetAdminsWithInvites)
d.OnMessagesGetChatInviteImporters(r.onMessagesGetChatInviteImporters)
d.OnMessagesHideChatJoinRequest(r.onMessagesHideChatJoinRequest)
d.OnMessagesHideAllChatJoinRequests(r.onMessagesHideAllChatJoinRequests)
d.OnUpdatesGetChannelDifference(r.onUpdatesGetChannelDifference)
}

View file

@ -0,0 +1,245 @@
package rpc
import (
"context"
"strings"
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// createBroadcastChannelRPC creates a broadcast channel owned by ownerID and returns its tg.Channel.
func createBroadcastChannelRPC(t *testing.T, r *Router, ownerID int64, title string) *tg.Channel {
t.Helper()
created, err := r.onChannelsCreateChannel(WithUserID(context.Background(), ownerID), &tg.ChannelsCreateChannelRequest{
Title: title,
About: "owned channel",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast %q: %v", title, err)
}
channel, ok := created.(*tg.Updates).Chats[0].(*tg.Channel)
if !ok || !channel.Broadcast {
t.Fatalf("create broadcast %q result = %#v, want broadcast channel", title, created)
}
return channel
}
func findSendAsChannel(peers []tg.SendAsPeer, channelID int64) (tg.SendAsPeer, bool) {
for _, p := range peers {
if ch, ok := p.Peer.(*tg.PeerChannel); ok && ch.ChannelID == channelID {
return p, true
}
}
return tg.SendAsPeer{}, false
}
func chatsContainChannel(chats []tg.ChatClass, channelID int64) bool {
for _, c := range chats {
switch ch := c.(type) {
case *tg.Channel:
if ch.ID == channelID {
return true
}
case *tg.ChannelForbidden:
if ch.ID == channelID {
return true
}
}
}
return false
}
// TestChannelSendAsForeignChannelRPC verifies a premium user can post in another supergroup as one
// of their own broadcast channels: getSendAs lists the owned channel (flagged premium_required), the
// send path stamps from_id to that channel and ships it in Chats, a non-owner is rejected, and the
// default round-trips into channelFull.default_send_as.
func TestChannelSendAsForeignChannelRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
premiumUntil := int(time.Now().Add(72 * time.Hour).Unix())
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner", PremiumUntil: premiumUntil})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Friend", PremiumUntil: premiumUntil})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
friendCtx := WithUserID(ctx, friend.ID)
// Target supergroup G (owner creator, friend member) and an owned broadcast channel C.
created, err := r.onMessagesCreateChat(ownerCtx, &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Foreign Send As Group",
})
if err != nil {
t.Fatalf("create group: %v", err)
}
group := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
owned := createBroadcastChannelRPC(t, r, owner.ID, "Owner Channel")
// getSendAs(G) by owner: self + G + owned channel; owned channel carries premium_required and is in Chats.
sendAs, err := r.onChannelsGetSendAs(ownerCtx, &tg.ChannelsGetSendAsRequest{Peer: inputPeerChannel(group)})
if err != nil {
t.Fatalf("owner get send as: %v", err)
}
if _, ok := findSendAsChannel(sendAs.Peers, group.ID); !ok {
t.Fatalf("send as peers %+v missing current group %d", sendAs.Peers, group.ID)
}
ownedPeer, ok := findSendAsChannel(sendAs.Peers, owned.ID)
if !ok {
t.Fatalf("send as peers %+v missing owned channel %d", sendAs.Peers, owned.ID)
}
if !ownedPeer.PremiumRequired {
t.Fatalf("owned foreign channel candidate premium_required = false, want true")
}
if grpPeer, _ := findSendAsChannel(sendAs.Peers, group.ID); grpPeer.PremiumRequired {
t.Fatalf("current group candidate premium_required = true, want false")
}
if !chatsContainChannel(sendAs.Chats, owned.ID) {
t.Fatalf("send as chats %+v missing owned channel object %d", sendAs.Chats, owned.ID)
}
// Owner (premium) sends in G as the owned channel: from_id projected to the channel, channel in Chats.
sent, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(group),
Message: "as my channel",
RandomID: 6101,
SendAs: inputPeerChannel(owned),
})
if err != nil {
t.Fatalf("send as owned foreign channel: %v", err)
}
sentUpdates := sent.(*tg.Updates)
sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != owned.ID {
t.Fatalf("send_as message from = %#v, want owned channel %d", sentMsg.FromID, owned.ID)
}
// The sender still sees their own send-as message as outgoing (out derives from SenderUserID, not from_id).
if !sentMsg.Out {
t.Fatalf("send_as message out = false for sender, want true")
}
if !chatsContainChannel(sentUpdates.Chats, owned.ID) {
t.Fatalf("send_as echo chats %+v missing owned channel %d", sentUpdates.Chats, owned.ID)
}
// A different member who does not own the channel cannot post as it.
if _, err := r.onMessagesSendMessage(friendCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(group),
Message: "not my channel",
RandomID: 6102,
SendAs: inputPeerChannel(owned),
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("non-owner send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
// saveDefaultSendAs(G, ownedChannel) by owner round-trips into channelFull.default_send_as + Chats.
if ok, err := r.onMessagesSaveDefaultSendAs(ownerCtx, &tg.MessagesSaveDefaultSendAsRequest{
Peer: inputPeerChannel(group),
SendAs: inputPeerChannel(owned),
}); err != nil || !ok {
t.Fatalf("save default send as owned channel = ok %v err %v, want true", ok, err)
}
full, err := r.onChannelsGetFullChannel(ownerCtx, inputChannel(group))
if err != nil {
t.Fatalf("get full channel after default send as: %v", err)
}
channelFull := full.FullChat.(*tg.ChannelFull)
def, ok := channelFull.GetDefaultSendAs()
if !ok {
t.Fatalf("channelFull default_send_as missing after saving owned channel")
}
if peer, ok := def.(*tg.PeerChannel); !ok || peer.ChannelID != owned.ID {
t.Fatalf("channelFull default_send_as = %#v, want owned channel %d", def, owned.ID)
}
if !chatsContainChannel(full.Chats, owned.ID) {
t.Fatalf("getFullChannel chats %+v missing owned default send-as channel %d", full.Chats, owned.ID)
}
// With the default stored, a send carrying no explicit send_as posts as the owned channel.
defaultSent, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(group),
Message: "default channel",
RandomID: 6103,
})
if err != nil {
t.Fatalf("send with stored default send_as: %v", err)
}
defaultMsg := defaultSent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := defaultMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != owned.ID {
t.Fatalf("stored-default send_as from = %#v, want owned channel %d", defaultMsg.FromID, owned.ID)
}
}
// TestChannelSendAsForeignChannelPremiumGateRPC verifies that posting as an owned personal channel in
// another supergroup is gated on Premium: a non-premium owner is rejected with SEND_AS_PEER_INVALID,
// and the same owner succeeds once Premium is active.
func TestChannelSendAsForeignChannelPremiumGateRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
// creator of the group is premium so the group exists with a member; the send-as actor is non-premium.
host, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Host", PremiumUntil: int(time.Now().Add(72 * time.Hour).Unix())})
actor, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Actor"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
hostCtx := WithUserID(ctx, host.ID)
actorCtx := WithUserID(ctx, actor.ID)
created, err := r.onMessagesCreateChat(hostCtx, &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: actor.ID, AccessHash: actor.AccessHash}},
Title: "Premium Gate Group",
})
if err != nil {
t.Fatalf("create group: %v", err)
}
group := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
actorChannel := createBroadcastChannelRPC(t, r, actor.ID, "Actor Channel")
// Non-premium actor cannot post as their own channel in the group.
if _, err := r.onMessagesSendMessage(actorCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(group),
Message: "non-premium send as",
RandomID: 7201,
SendAs: inputPeerChannel(actorChannel),
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("non-premium send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
// And cannot persist it as the default.
if _, err := r.onMessagesSaveDefaultSendAs(actorCtx, &tg.MessagesSaveDefaultSendAsRequest{
Peer: inputPeerChannel(group),
SendAs: inputPeerChannel(actorChannel),
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("non-premium save default send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
// Grant Premium and the same send succeeds.
if _, err := userStore.SetPremiumUntil(ctx, actor.ID, int(time.Now().Add(72*time.Hour).Unix())); err != nil {
t.Fatalf("grant premium: %v", err)
}
sent, err := r.onMessagesSendMessage(actorCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(group),
Message: "premium send as",
RandomID: 7202,
SendAs: inputPeerChannel(actorChannel),
})
if err != nil {
t.Fatalf("premium send_as: %v", err)
}
sentMsg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != actorChannel.ID {
t.Fatalf("premium send_as from = %#v, want actor channel %d", sentMsg.FromID, actorChannel.ID)
}
}

View file

@ -0,0 +1,473 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"strings"
"telesrv/internal/domain"
"unicode/utf8"
)
func (r *Router) onChannelsCheckUsername(ctx context.Context, req *tg.ChannelsCheckUsernameRequest) (bool, error) {
if r.deps.Channels == nil {
return false, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return false, err
}
okUsername, err := r.deps.Channels.CheckUsername(ctx, userID, channelID, req.Username)
if err != nil {
return false, channelUsernameErr(err)
}
return okUsername, nil
}
func (r *Router) onChannelsUpdateUsername(ctx context.Context, req *tg.ChannelsUpdateUsernameRequest) (bool, error) {
if r.deps.Channels == nil {
return false, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return false, err
}
channel, err := r.deps.Channels.UpdateUsername(ctx, userID, domain.UpdateChannelUsernameRequest{
UserID: userID,
ChannelID: channelID,
Username: req.Username,
})
if err != nil {
return false, channelUsernameErr(err)
}
r.invalidateRPCProjectionForChannel(channel.ID)
r.pushChannelStateToMembers(ctx, userID, channel)
return true, nil
}
func (r *Router) onChannelsToggleSignatures(ctx context.Context, req *tg.ChannelsToggleSignaturesRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetSignatures(ctx, userID, channelID, req.SignaturesEnabled)
})
}
func (r *Router) onChannelsTogglePreHistoryHidden(ctx context.Context, req *tg.ChannelsTogglePreHistoryHiddenRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetPreHistoryHidden(ctx, userID, channelID, req.Enabled)
})
}
func (r *Router) onChannelsToggleSlowMode(ctx context.Context, req *tg.ChannelsToggleSlowModeRequest) (tg.UpdatesClass, error) {
if !domain.ValidChannelSlowModeSeconds(req.Seconds) {
return nil, secondsInvalidErr()
}
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetSlowMode(ctx, userID, channelID, req.Seconds)
})
}
func (r *Router) onChannelsSetStickers(ctx context.Context, req *tg.ChannelsSetStickersRequest) (bool, error) {
_, 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 err := validateEmptyChannelStickerSet(req.Stickerset); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.ChannelsSetEmojiStickersRequest) (bool, error) {
_, 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 err := validateEmptyChannelStickerSet(req.Stickerset); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) {
if len(req.Order) > maxChannelUsernameOrder {
return false, limitInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) {
if req.Username != "" && !validChannelManagementUsername(req.Username) {
return false, usernameInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); 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 {
return false, err
}
return true, nil
}
func (r *Router) onChannelsUpdateColor(ctx context.Context, req *tg.ChannelsUpdateColorRequest) (tg.UpdatesClass, error) {
return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) {
return r.deps.Channels.SetColor(ctx, userID, view.Channel.ID, req.ForProfile, domainPeerColorFromChannelUpdate(req))
})
}
func (r *Router) onChannelsUpdateEmojiStatus(ctx context.Context, req *tg.ChannelsUpdateEmojiStatusRequest) (tg.UpdatesClass, error) {
viewerUserID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return nil, err
}
status, err := domainChannelEmojiStatus(req.EmojiStatus)
if err != nil {
return nil, err
}
channel, err := r.deps.Channels.SetEmojiStatus(ctx, viewerUserID, view.Channel.ID, status)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, viewerUserID, channel), nil
}
func (r *Router) onChannelsSetDiscussionGroup(ctx context.Context, req *tg.ChannelsSetDiscussionGroupRequest) (bool, error) {
if r.deps.Channels == nil {
return false, notImplementedErr()
}
if req == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
broadcastID, err := r.optionalChannelIDFromInput(ctx, userID, req.Broadcast)
if err != nil {
return false, channelDiscussionErr(err)
}
groupID, err := r.optionalChannelIDFromInput(ctx, userID, req.Group)
if err != nil {
return false, channelDiscussionErr(err)
}
res, err := r.deps.Channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID)
if err != nil {
return false, channelDiscussionErr(err)
}
for _, channel := range res.Channels {
r.invalidateRPCProjectionForChannel(channel.ID)
r.pushChannelStateToMembers(ctx, userID, channel)
}
return true, nil
}
func (r *Router) onChannelsEditLocation(ctx context.Context, req *tg.ChannelsEditLocationRequest) (bool, error) {
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsConvertToGigagroup(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) {
userID, view, err := r.channelChangeInfoView(ctx, input)
if err != nil {
return nil, err
}
return r.channelStateUpdates(userID, view.Channel), nil
}
func (r *Router) onChannelsToggleAntiSpam(ctx context.Context, req *tg.ChannelsToggleAntiSpamRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetAntiSpam(ctx, userID, channelID, req.Enabled)
})
}
func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req *tg.ChannelsReportAntiSpamFalsePositiveRequest) (bool, error) {
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsGetChannelRecommendations(ctx context.Context, req *tg.ChannelsGetChannelRecommendationsRequest) (tg.MessagesChatsClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Channels == nil {
return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil
}
sourceChannelID := int64(0)
if req != nil {
if input, ok := req.GetChannel(); ok {
source, err := r.publicRecommendationSourceChannel(ctx, userID, input)
if err != nil {
return nil, err
}
sourceChannelID = source
}
}
res, err := r.deps.Channels.ChannelRecommendations(ctx, userID, domain.ChannelRecommendationsRequest{
UserID: userID,
SourceChannelID: sourceChannelID,
Limit: domain.DefaultChannelRecommendationsLimit,
})
if err != nil {
return nil, channelInvalidErr(err)
}
chats := tgChannels(userID, res.Channels)
if res.Count > len(chats) {
return &tg.MessagesChatsSlice{Count: res.Count, Chats: chats}, nil
}
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onChannelsSetBoostsToUnblockRestrictions(ctx context.Context, req *tg.ChannelsSetBoostsToUnblockRestrictionsRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if req.Boosts < 0 || req.Boosts > maxChannelBoostsToUnblockRestrictions {
return nil, limitInvalidErr()
}
return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) {
return r.deps.Channels.SetBoostsToUnblockRestrictions(ctx, userID, view.Channel.ID, req.Boosts)
})
}
func (r *Router) onChannelsRestrictSponsoredMessages(ctx context.Context, req *tg.ChannelsRestrictSponsoredMessagesRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) {
return r.deps.Channels.SetRestrictedSponsored(ctx, userID, view.Channel.ID, req.Restricted)
})
}
func (r *Router) onChannelsUpdatePaidMessagesPrice(ctx context.Context, req *tg.ChannelsUpdatePaidMessagesPriceRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return nil, err
}
if err := validateChannelPaidMessagesPriceRequest(req, view.Channel); err != nil {
return nil, err
}
stars := req.SendPaidMessagesStars
if stars < 0 {
stars = 0
}
res, err := r.deps.Channels.SetPaidMessagesPrice(ctx, userID, view.Channel.ID, stars, req.BroadcastMessagesAllowed)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelPaidMessagesPriceUpdates(ctx, userID, res), nil
}
func validateChannelPaidMessagesPriceRequest(req *tg.ChannelsUpdatePaidMessagesPriceRequest, channel domain.Channel) error {
stars := req.SendPaidMessagesStars
if stars == -1 && channel.Broadcast && !req.BroadcastMessagesAllowed {
return nil
}
if stars < 0 || stars > maxChannelPaidMessageStars {
return starsAmountInvalidErr()
}
return nil
}
func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.ChannelsToggleAutotranslationRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) {
return r.deps.Channels.SetAutotranslation(ctx, userID, view.Channel.ID, req.Enabled)
})
}
func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if !validChannelTitle(req.Title) {
return nil, channelInvalidErr(domain.ErrChannelTitleInvalid)
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.EditTitle(ctx, userID, domain.EditChannelTitleRequest{
UserID: userID,
ChannelID: channelID,
Title: req.Title,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
updates := r.channelTitleUpdates(ctx, userID, res)
// 混合容器拆分(设计 fan-out epicchannelTitleUpdates 同时含①无 pts UpdateChannel频道元数据
// 刷新,无 channel difference 恢复面)②带 pts 改名服务消息broadcast+megagroup 均产 pts
// difference 恢复面)。①必须同步发(不能进可丢弃队列,否则永久漏人数/元数据刷新),但它无 Users
// 投影、廉价;②走异步 fan-out含 owner 预热 + >cap nudge丢弃由 getChannelDifference 兜底),把
// per-viewer 服务消息投影移出改名者 RPC 路径。操作者本设备仍由上面的 RPC result 即时回显完整容器。
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelStateUpdates(viewerUserID, res.Channel)
})
if res.Event.Pts != 0 {
r.enqueueChannelMessageFanout(ctx, userID, domain.SendChannelMessageResult{
Channel: res.Channel,
Message: res.Message,
Event: res.Event,
Recipients: res.Recipients,
}, nil)
}
return updates, nil
}
func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if req.Photo == nil {
return nil, photoInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo)
if err != nil {
return nil, err
}
channel, err := r.deps.Channels.SetPhoto(ctx, userID, channelID, photo)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
func (r *Router) channelTitleUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelTitleResult) *tg.Updates {
updates := []tg.UpdateClass{&tg.UpdateChannel{ChannelID: res.Channel.ID}}
if res.Event.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
updates = append(updates, update)
}
}
return &tg.Updates{
Updates: updates,
Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{res.Message.SenderUserID}),
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func validChannelTitle(title string) bool {
n := utf8.RuneCountInString(title)
return n > 0 && n <= maxChannelTitleLength
}
func validChannelManagementUsername(username string) bool {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if len(username) < 5 || len(username) > 32 {
return false
}
for i := 0; i < len(username); i++ {
c := username[i]
switch {
case i == 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')):
case i > 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'):
default:
return false
}
}
return true
}
func domainPeerColorFromChannelUpdate(req *tg.ChannelsUpdateColorRequest) domain.ChannelPeerColor {
if req == nil {
return domain.ChannelPeerColor{}
}
color, hasColor := req.GetColor()
backgroundEmojiID, hasBackground := req.GetBackgroundEmojiID()
out := domain.ChannelPeerColor{HasColor: hasColor, Color: color}
if hasBackground {
out.BackgroundEmojiID = backgroundEmojiID
}
return out
}
func domainChannelEmojiStatus(status tg.EmojiStatusClass) (domain.ChannelEmojiStatus, error) {
switch s := status.(type) {
case *tg.EmojiStatusEmpty:
return domain.ChannelEmojiStatus{}, nil
case *tg.EmojiStatus:
if s.DocumentID <= 0 {
return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
}
until, _ := s.GetUntil()
if until < 0 {
return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
}
return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil
case *tg.EmojiStatusCollectible:
if s.DocumentID <= 0 {
return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
}
until, _ := s.GetUntil()
if until < 0 {
return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
}
return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil
case *tg.InputEmojiStatusCollectible:
return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
default:
return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID")
}
}
func channelUsernameErr(err error) error {
switch {
case errors.Is(err, domain.ErrUsernameInvalid):
return usernameInvalidErr()
case errors.Is(err, domain.ErrUsernameOccupied):
return usernameOccupiedErr()
case errors.Is(err, domain.ErrChannelNotModified):
return usernameNotModifiedErr()
default:
return channelAdminErr(err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
type channelStateMutation func(ctx context.Context, userID, channelID int64) (domain.Channel, error)
func (r *Router) applyChannelAdminStateMutation(ctx context.Context, input tg.InputChannelClass, mutate channelStateMutation) (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.channelIDFromInput(ctx, userID, input)
if err != nil {
return nil, err
}
channel, err := mutate(ctx, userID, channelID)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
type channelChangeInfoMutation func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error)
func (r *Router) applyChannelChangeInfoMutation(ctx context.Context, input tg.InputChannelClass, mutate channelChangeInfoMutation) (tg.UpdatesClass, error) {
userID, view, err := r.channelChangeInfoView(ctx, input)
if err != nil {
return nil, err
}
channel, err := mutate(ctx, userID, view)
if err != nil {
return nil, channelAdminErr(err)
}
return r.channelStateMutationUpdates(ctx, userID, channel), nil
}
func (r *Router) channelStateMutationUpdates(ctx context.Context, userID int64, channel domain.Channel) tg.UpdatesClass {
r.invalidateRPCProjectionForChannel(channel.ID)
if channel.LinkedMonoforumID != 0 {
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)
}
func (r *Router) channelPaidMessagesPriceUpdates(ctx context.Context, userID int64, res domain.ChannelPaidMessagesPriceResult) tg.UpdatesClass {
state := r.channelStateMutationUpdates(ctx, userID, res.Channel)
services := res.ServiceMessages
if len(services) == 0 && res.ServiceMessage != nil {
services = []domain.SendChannelMessageResult{*res.ServiceMessage}
}
if len(services) == 0 {
return state
}
service := r.channelMessagesUpdatesWithPeerCache(ctx, userID, services, nil, false, nil, newViewerPeerCache(r))
if updates, ok := state.(*tg.Updates); ok {
return mergeUpdates(updates, service)
}
return state
}
func mergeUpdates(a, b *tg.Updates) *tg.Updates {
if a == nil {
return b
}
if b == nil {
return a
}
a.Updates = append(a.Updates, b.Updates...)
a.Users = appendUniqueTGUsers(a.Users, b.Users...)
a.Chats = appendUniqueTGChats(a.Chats, b.Chats...)
if b.Date > a.Date {
a.Date = b.Date
}
return a
}

View file

@ -0,0 +1,715 @@
package rpc
import (
"context"
"errors"
"fmt"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/domain"
)
func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error {
if _, ok := stickerset.(*tg.InputStickerSetEmpty); ok {
return nil
}
return stickersetInvalidErr()
}
func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) {
if len(req.ID) > maxChannelReportMessageIDs {
return false, limitInvalidErr()
}
for _, id := range req.ID {
if id <= 0 || id > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
}
if _, _, err := r.channelView(ctx, req.Channel); err != nil {
return false, err
}
if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return false, peerIDInvalidErr()
}
return true, nil
}
func (r *Router) publicRecommendationSourceChannel(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) {
ref, ok := inputChannelRef(input)
if !ok {
return 0, channelInvalidErr(domain.ErrChannelInvalid)
}
channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID)
if err != nil {
return 0, channelInvalidErr(err)
}
if !inputChannelAccessHashMatches(ref, channel) {
return 0, channelInvalidErr(domain.ErrChannelPrivate)
}
if channel.Deleted || !channel.Broadcast || channel.Megagroup || channel.Username == "" {
return 0, channelInvalidErr(domain.ErrChannelInvalid)
}
return channel.ID, nil
}
func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.ChannelsSetMainProfileTabRequest) (bool, error) {
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
return false, err
}
return true, nil
}
func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (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.channelIDFromInput(ctx, userID, input)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.DeleteChannel(ctx, userID, domain.DeleteChannelRequest{
UserID: userID,
ChannelID: channelID,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
updates := r.channelStateUpdates(userID, res.Channel)
// 母频道随带删的 monoforum 必须同样以 ChannelForbidden 墓碑下发,否则在线客户端会留着 mono 会话
// (isMonoforum=true 但 link 已不可解析)继续渲染崩溃。对操作者本设备(updates)与所有接收方都补一条。
if mono := res.LinkedMonoforum; mono != nil {
r.invalidateChannelFullBotInfoCacheForChannel(mono.ID)
appendChannelStateUpdates(updates, r.channelStateUpdates(userID, *mono))
}
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
upd := r.channelStateUpdates(viewerUserID, res.Channel)
if mono := res.LinkedMonoforum; mono != nil {
appendChannelStateUpdates(upd, r.channelStateUpdates(viewerUserID, *mono))
}
return upd
})
r.removeOnlineChannelMembershipsForOnlineMembers(res.Channel.ID)
if mono := res.LinkedMonoforum; mono != nil {
r.removeOnlineChannelMembershipsForOnlineMembers(mono.ID)
}
return updates, nil
}
func (r *Router) onMessagesUnpinAllMessages(ctx context.Context, req *tg.MessagesUnpinAllMessagesRequest) (*tg.MessagesAffectedHistory, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID <= 0 || topMsgID > domain.MaxMessageBoxID) {
return nil, messageIDInvalidErr()
}
if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil {
return nil, err
}
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
// 私聊置顶按账号 pts 记账;返回 0 会让 Android 误判 pts 空洞多拉
// 一轮 difference。
authKeyID, _ := AuthKeyIDFrom(ctx)
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return r.affectedHistory(ctx, authKeyID, userID, 0)
}
if topMsgID, ok := req.GetTopMsgID(); ok && topMsgID > 0 {
// 私聊无 forum topic 维度,按无置顶可清处理。
return r.affectedHistory(ctx, authKeyID, userID, 0)
}
if _, ok := req.GetSavedPeerID(); ok {
// Saved Messages 子会话置顶维度未实现,避免误清全局置顶。
return r.affectedHistory(ctx, authKeyID, userID, 0)
}
return r.unpinAllPrivateMessages(ctx, userID, peer)
}
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID)
if err != nil {
return nil, channelAdminErr(err)
}
if topMsgID, ok := req.GetTopMsgID(); ok && topMsgID > 0 {
return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil
}
if _, ok := req.GetSavedPeerID(); ok {
return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil
}
res, err := r.deps.Channels.UnpinAllMessages(ctx, userID, domain.UnpinAllChannelMessagesRequest{
UserID: userID,
ChannelID: peer.ID,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
if errors.Is(err, domain.ErrChannelNotModified) {
return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil
}
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 {
return r.channelPinnedUpdates(viewerUserID, res)
})
return &tg.MessagesAffectedHistory{
Pts: res.Event.Pts,
PtsCount: res.Event.PtsCount,
Offset: 0,
}, nil
}
// createChatNeedsLegacyChat decides whether messages.createChat returns the
// legacy chat-shaped response. Old DrKLO/TDesktop clients (and pre-initConnection
// sessions) expect it; modern clients get the canonical InvitedUsers response.
// The old createChat#34a818 id now upgrades via layerwire to the canonical id,
// and android client metadata is applied during that upgrade, so keying on
// ClientType (below) is sufficient — no per-request ctx flag is needed.
func createChatNeedsLegacyChat(ctx context.Context) bool {
if _, ok := ClientInfoFrom(ctx); !ok {
_, hasSession := SessionIDFrom(ctx)
return hasSession
}
switch ClientTypeFrom(ctx) {
case ClientTypeTDesktop, ClientTypeAndroid:
return true
default:
return false
}
}
func channelMemberForUser(members []domain.ChannelMember, userID int64) *domain.ChannelMember {
for i := range members {
if members[i].UserID == userID {
return &members[i]
}
}
return nil
}
func mergeChannelMembers(a, b []domain.ChannelMember) []domain.ChannelMember {
if len(a) == 0 {
return append([]domain.ChannelMember(nil), b...)
}
if len(b) == 0 {
return append([]domain.ChannelMember(nil), a...)
}
out := make([]domain.ChannelMember, 0, len(a)+len(b))
seen := make(map[int64]struct{}, len(a)+len(b))
appendOne := func(member domain.ChannelMember) {
if member.UserID == 0 {
return
}
if _, ok := seen[member.UserID]; ok {
return
}
seen[member.UserID] = struct{}{}
out = append(out, member)
}
for _, member := range a {
appendOne(member)
}
for _, member := range b {
appendOne(member)
}
return out
}
func peerIDsExcept(ids []int64, skipIDs ...int64) []int64 {
unique := uniquePeerIDs(ids)
if len(unique) == 0 || len(skipIDs) == 0 {
return unique
}
skip := make(map[int64]struct{}, len(skipIDs))
for _, id := range skipIDs {
if id != 0 {
skip[id] = struct{}{}
}
}
out := unique[:0]
for _, id := range unique {
if _, ok := skip[id]; ok {
continue
}
out = append(out, id)
}
return out
}
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, 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
}
date := int(r.clock.Now().Unix())
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Date: date,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: read.ChannelID},
MaxID: read.MaxID,
StillUnreadCount: read.StillUnreadCount,
ChannelPts: read.Pts,
FolderID: read.Dialog.FolderID,
PtsCount: 1,
}
// event.Pts 是账号 pts 槽位,只能来自真实 durable 记录channel pts
// 永远只放 ChannelPts混填会让 pts 簿记把 channel 序列当账号序列。
recordedEvent := event
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
recorded, _, err := r.deps.Updates.RecordReadHistory(ctx, authKeyID, userID, domain.ReadHistoryResult{
OwnerUserID: userID,
Peer: event.Peer,
MaxID: read.MaxID,
StillUnreadCount: read.StillUnreadCount,
ChannelPts: read.Pts,
Changed: read.Changed,
}, sessionID)
if err != nil {
return domain.UpdateEvent{}, internalErr()
}
recordedEvent = recorded
}
r.pushCurrentReadHistoryEvent(ctx, recordedEvent)
r.pushReadHistoryEvent(ctx, userID, recordedEvent)
return recordedEvent, nil
}
func (r *Router) channelFanoutRecipients(ctx context.Context, scope channelFanoutScope, channelID int64, explicit []int64) []int64 {
if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil {
return uniqueRecipientIDs(explicit)
}
if scope == channelFanoutExplicit {
return uniqueRecipientIDs(explicit)
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return uniqueRecipientIDs(explicit)
}
// 实时推送按 MaxChannelRealtimeFanout 封顶:每个收件人都要逐 viewer 重建 payload
// 并批量解析 users放开会把高频操作如 reaction放大成 O(全部在线成员) 的逐条推送。
var online []int64
switch scope {
case channelFanoutMembers:
online = provider.OnlineChannelMemberUserIDs(channelID, domain.MaxChannelRealtimeFanout)
case channelFanoutViewers:
online = provider.OnlineChannelUserIDs(channelID, domain.MaxChannelRealtimeFanout)
}
if len(online) == 0 {
return uniqueRecipientIDs(explicit)
}
active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online)
if err != nil {
return uniqueRecipientIDs(explicit)
}
if len(active) == 0 && len(explicit) == 0 {
return nil
}
if len(active) > domain.MaxChannelRealtimeFanout {
active = active[:domain.MaxChannelRealtimeFanout]
}
out := uniqueRecipientIDs(active)
seen := make(map[int64]struct{}, len(out)+len(explicit))
for _, userID := range active {
if userID == 0 {
continue
}
seen[userID] = struct{}{}
}
// Keep operation-specific recipients as a fallback: leave/kick/delete flows
// may need to notify a user who is no longer an active member after commit.
for _, userID := range explicit {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
out = append(out, userID)
}
return out
}
func uniqueRecipientIDs(ids []int64) []int64 {
if len(ids) == 0 {
return nil
}
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, userID := range ids {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
out = append(out, userID)
}
return out
}
func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) {
r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false)
}
func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) {
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)
})
}
func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass {
if r.deps.Users == nil || len(ids) == 0 {
return nil
}
unique := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
users, err := r.deps.Users.ByIDs(ctx, currentUserID, unique)
if err != nil {
// 批量解析失败(通常是 DB 故障)不静默丢弃:批量语义下无部分结果,记日志便于排查。
// update 仍以空 users 列表推送,客户端用本地缓存或后续 getUser 补齐,不致命。
// 不降级逐个查询以免 DB 抖动时把一次失败放大成 N 次查询。
r.log.Warn("batch resolve users for channel update failed",
zap.Int("count", len(unique)), zap.Error(err))
return nil
}
byID := make(map[int64]domain.User, len(users))
for _, u := range users {
if u.ID != 0 {
byID[u.ID] = u
}
}
out := make([]tg.UserClass, 0, len(byID))
for _, id := range unique {
u, ok := byID[id]
if !ok {
continue
}
u = r.withUserPresence(u)
if id == currentUserID {
out = append(out, tgSelfUser(u))
continue
}
out = append(out, tgUser(u))
}
r.withBotProfileFlagsForUsers(ctx, out)
return out
}
func (r *Router) inviteManagementChannelView(ctx context.Context, peer tg.InputPeerClass) (int64, domain.ChannelView, error) {
ref, ok := inviteManagementChannelRef(peer)
if !ok {
return 0, domain.ChannelView{}, peerIDInvalidErr()
}
input := &tg.InputChannel{ChannelID: ref.ID}
if ref.CheckAccessHash {
input.AccessHash = ref.AccessHash
}
userID, view, err := r.channelView(ctx, input)
if err != nil {
return 0, domain.ChannelView{}, err
}
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED")
}
return userID, view, nil
}
func inviteManagementChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) {
switch p := peer.(type) {
case *tg.InputPeerChannel:
if p == nil {
return channelInputRef{}, false
}
return channelInputRef{
ID: p.ChannelID,
AccessHash: p.AccessHash,
CheckAccessHash: p.AccessHash != 0,
}, p.ChannelID > 0
case *tg.InputPeerChannelFromMessage:
if p == nil {
return channelInputRef{}, false
}
return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0
case *tg.InputPeerChat:
if p == nil {
return channelInputRef{}, false
}
return channelInputRef{ID: p.ChatID}, p.ChatID > 0
default:
return channelInputRef{}, false
}
}
func inputUserIsEmpty(input tg.InputUserClass) bool {
switch input.(type) {
case nil, *tg.InputUserEmpty:
return true
default:
return false
}
}
func (r *Router) userIDsFromInputUsers(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) {
out := make([]int64, 0, len(inputs))
seen := make(map[int64]struct{}, len(inputs))
for _, input := range inputs {
u, found, err := r.userFromInput(ctx, currentUserID, input)
if err != nil {
return nil, internalErr()
}
if !found || u.ID == 0 {
return nil, peerIDInvalidErr()
}
if _, ok := seen[u.ID]; ok {
continue
}
seen[u.ID] = struct{}{}
out = append(out, u.ID)
}
return out, nil
}
type channelInputRef struct {
ID int64
AccessHash int64
CheckAccessHash bool
}
func inputChannelRef(input tg.InputChannelClass) (channelInputRef, bool) {
switch channel := input.(type) {
case *tg.InputChannel:
return channelInputRef{
ID: channel.ChannelID,
AccessHash: channel.AccessHash,
CheckAccessHash: channel.AccessHash != 0,
}, channel.ChannelID > 0
case *tg.InputChannelFromMessage:
return channelInputRef{ID: channel.ChannelID}, channel.ChannelID > 0
default:
return channelInputRef{}, false
}
}
func inputChannelAccessHashMatches(ref channelInputRef, channel domain.Channel) bool {
return !ref.CheckAccessHash || ref.AccessHash == channel.AccessHash
}
func (r *Router) optionalChannelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) {
switch input.(type) {
case nil, *tg.InputChannelEmpty:
return 0, nil
default:
return r.channelIDFromInput(ctx, userID, input)
}
}
func (r *Router) channelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) {
ref, ok := inputChannelRef(input)
if !ok {
return 0, channelInvalidErr(domain.ErrChannelInvalid)
}
if !ref.CheckAccessHash || r.deps.Channels == nil {
return ref.ID, nil
}
// 只校验 access_hash + 返回 ID走轻量 ResolveChannel省 dialog/读态/boost 这 3 条查询)。
// 本函数被 23 个频道 RPC 复用,其中 getChannelDifference 被客户端按打开频道每秒轮询——是
// 完整 GetChannel 投影放大的主源头。返回契约不变(仅 ID + 越权校验)。
view, err := r.deps.Channels.ResolveChannel(ctx, userID, ref.ID)
if err != nil {
return 0, channelInvalidErr(err)
}
if !inputChannelAccessHashMatches(ref, view.Channel) {
return 0, channelInvalidErr(domain.ErrChannelPrivate)
}
return ref.ID, nil
}
func (r *Router) channelView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) {
if r.deps.Channels == nil {
return 0, domain.ChannelView{}, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, domain.ChannelView{}, internalErr()
}
ref, ok := inputChannelRef(input)
if !ok {
return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid)
}
view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID)
if err != nil {
return 0, domain.ChannelView{}, channelInvalidErr(err)
}
if !inputChannelAccessHashMatches(ref, view.Channel) {
return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate)
}
return userID, view, nil
}
func (r *Router) channelChangeInfoView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) {
if r.deps.Channels == nil {
return 0, domain.ChannelView{}, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, domain.ChannelView{}, internalErr()
}
ref, ok := inputChannelRef(input)
if !ok {
return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid)
}
view, err := r.deps.Channels.GetChannelForChangeInfo(ctx, userID, ref.ID)
if err != nil {
return 0, domain.ChannelView{}, channelAdminErr(err)
}
if !inputChannelAccessHashMatches(ref, view.Channel) {
return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate)
}
return userID, view, nil
}
func channelIDFromLegacyInputPeer(userID int64, peer tg.InputPeerClass) (int64, bool) {
switch p := peer.(type) {
case *tg.InputPeerChannel:
if p == nil {
return 0, false
}
return p.ChannelID, p.ChannelID > 0
case *tg.InputPeerChat:
if p == nil {
return 0, false
}
return p.ChatID, p.ChatID > 0
case *tg.InputPeerChannelFromMessage:
if p == nil {
return 0, false
}
return p.ChannelID, p.ChannelID > 0
default:
return 0, false
}
}
func (r *Router) channelIDFromLegacyInputPeerChecked(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) {
channelID, ok := channelIDFromLegacyInputPeer(userID, peer)
if !ok {
return 0, peerIDInvalidErr()
}
if err := r.validateInputPeerChannelAccess(ctx, userID, peer, channelID); err != nil {
return 0, err
}
return channelID, nil
}
func channelInvalidErr(err error) error {
switch {
case errors.Is(err, domain.ErrUserSendRestricted):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrChannelTitleInvalid):
return tgerr400("CHAT_TITLE_EMPTY")
case errors.Is(err, domain.ErrChannelInvalid):
return tgerr400("CHANNEL_INVALID")
case errors.Is(err, domain.ErrChannelPrivate):
return tgerr400("CHANNEL_PRIVATE")
case errors.Is(err, domain.ErrChannelUserBanned):
return tgerr400("USER_BANNED_IN_CHANNEL")
case errors.Is(err, domain.ErrChannelWriteForbidden):
return tgerr400("CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrChannelAdminRequired):
return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrUserAlreadyParticipant):
return tgerr400("USER_ALREADY_PARTICIPANT")
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()
default:
if seconds, ok := domain.SlowModeWaitSeconds(err); ok {
return tgerr.New(420, fmt.Sprintf("SLOWMODE_WAIT_%d", seconds))
}
return internalErr()
}
}
func channelDeleteErr(err error) error {
switch {
case errors.Is(err, domain.ErrMessageIDInvalid):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrMessageAuthorRequired):
return messageAuthorRequiredErr()
case errors.Is(err, domain.ErrChannelAdminRequired):
// 官方对 channels.deleteMessages 的越权删除返回该错误码。
return tgerr400("MESSAGE_DELETE_FORBIDDEN")
default:
return channelInvalidErr(err)
}
}
func channelDiscussionErr(err error) error {
switch {
case errors.Is(err, domain.ErrLinkNotModified):
return tgerr400("LINK_NOT_MODIFIED")
case errors.Is(err, domain.ErrBroadcastIDInvalid):
return tgerr400("BROADCAST_ID_INVALID")
case errors.Is(err, domain.ErrMegagroupIDInvalid):
return tgerr400("MEGAGROUP_ID_INVALID")
case errors.Is(err, domain.ErrMegagroupPrehistoryHidden):
return tgerr400("MEGAGROUP_PREHISTORY_HIDDEN")
default:
return channelAdminErr(err)
}
}
func tgerr400(message string) error {
return tgerr.New(400, message)
}

View file

@ -0,0 +1,114 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onChannelsToggleForum(ctx context.Context, req *tg.ChannelsToggleForumRequest) (tg.UpdatesClass, error) {
return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
return r.deps.Channels.SetForum(ctx, userID, channelID, req.Enabled, req.Tabs)
})
}
func (r *Router) onChannelsToggleViewForumAsMessages(ctx context.Context, req *tg.ChannelsToggleViewForumAsMessagesRequest) (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.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
changed, err := r.deps.Channels.SetViewForumAsMessages(ctx, userID, channelID, req.Enabled)
if err != nil {
return nil, channelInvalidErr(err)
}
if !changed {
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}
r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
event := domain.UpdateEvent{
Type: domain.UpdateEventChannelViewForum,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
Bool: req.Enabled,
PtsCount: 1,
Date: int(r.clock.Now().Unix()),
}
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordChannelViewForumAsMessages(ctx, authKeyID, userID, channelID, req.Enabled, sessionID)
if err != nil {
return nil, internalErr()
}
}
out := tgUpdateForOutboxEvent(event)
if out == nil {
out = tgEmptyUpdates(event.Date)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
return out, nil
}
func (r *Router) onMessagesUpdatePinnedMessage(ctx context.Context, req *tg.MessagesUpdatePinnedMessageRequest) (tg.UpdatesClass, error) {
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 req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if peer.Type == domain.PeerTypeUser && peer.ID != 0 {
return r.updatePrivatePinnedMessage(ctx, userID, peer, req)
}
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
res, err := r.deps.Channels.UpdatePinnedMessage(ctx, userID, domain.UpdateChannelPinnedMessageRequest{
UserID: userID,
ChannelID: peer.ID,
MessageID: req.ID,
Pinned: !req.Unpin,
Silent: req.Silent,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelAdminErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
updates := r.channelPinnedUpdates(userID, res)
// pin fan-out 异步化(设计 Phase 0与已异步的 unpinAll(channels_stubs.go) 对齐。
// 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 {
return r.channelPinnedUpdates(viewerUserID, res)
})
return updates, nil
}
func (r *Router) channelPinnedUpdates(viewerUserID int64, res domain.UpdateChannelPinnedMessageResult) *tg.Updates {
updates := []tg.UpdateClass(nil)
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
updates = append(updates, update)
}
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
return &tg.Updates{
Updates: updates,
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}

View file

@ -0,0 +1,404 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.UpdatesGetChannelDifferenceRequest) (tg.UpdatesChannelDifferenceClass, error) {
if r.deps.Channels == nil {
return &tg.UpdatesChannelDifferenceEmpty{Final: true, Pts: req.Pts, Timeout: 30}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
// difference 类 catch-up FLOOD_WAIT设计 Phase 2 / §10.3nudge 被消费后客户端会触发
// getChannelDifference大群 nudge 全速前需限速防风暴。未配置阈值时不限速。
if err := r.checkCatchupRateLimit(ctx, userID, channelDifferenceRateLimitKeyPrefix); err != nil {
return nil, err
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
r.trackChannelInterest(ctx, userID, channelID)
diff, err := r.deps.Channels.GetDifference(ctx, userID, domain.ChannelDifferenceRequest{
UserID: userID,
ChannelID: channelID,
Pts: req.Pts,
Limit: req.Limit,
Force: req.Force,
})
if err != nil {
if errors.Is(err, domain.ErrPersistentTimestamp) {
return nil, persistentTimestampInvalidErr()
}
return nil, channelInvalidErr(err)
}
diff = r.enrichChannelDifference(ctx, userID, diff)
return tgChannelDifference(userID, diff), nil
}
func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates {
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, newViewerPeerCache(r))
}
func (r *Router) channelOperationUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult, cache *viewerPeerCache) *tg.Updates {
if cache == nil {
cache = newViewerPeerCache(r)
}
userIDs := make(map[int64]struct{}, len(res.Members)+4)
channelIDs := make(map[int64]struct{})
if res.Channel.CreatorUserID != 0 {
userIDs[res.Channel.CreatorUserID] = struct{}{}
}
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
for _, member := range res.Members {
if member.UserID != 0 {
userIDs[member.UserID] = struct{}{}
}
if member.InviterUserID != 0 {
userIDs[member.InviterUserID] = struct{}{}
}
}
updates := make([]tg.UpdateClass, 0, 2)
if res.Event.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
updates = append(updates, update)
}
}
if res.Channel.ID != 0 {
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
}
chats := []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, channelMemberForUser(res.Members, viewerUserID))}
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), res.Channel.ID)))...)
return &tg.Updates{
Updates: updates,
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
Chats: chats,
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func (r *Router) tdesktopCreateChatUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult, cache *viewerPeerCache) *tg.Updates {
updates := r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
if updates == nil {
return updates
}
self := channelMemberForUser(res.Members, viewerUserID)
legacy := tgMigratedLegacyChat(viewerUserID, res.Channel, self)
if legacy == nil {
return updates
}
chats := make([]tg.ChatClass, 0, len(updates.Chats)+1)
chats = append(chats, legacy)
chats = append(chats, updates.Chats...)
updates.Chats = chats
return updates
}
func (r *Router) channelStateUpdates(viewerUserID int64, channel domain.Channel) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: channel.ID}},
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
// appendChannelStateUpdates 把 extra 的 update/chat 合并进 dst(chats 按 id 去重)。用于在一次响应里
// 叠加关联频道的状态更新,例如随母频道删除一并下发的 monoforum ChannelForbidden 墓碑。
func appendChannelStateUpdates(dst *tg.Updates, extra *tg.Updates) {
if dst == nil || extra == nil {
return
}
dst.Updates = append(dst.Updates, extra.Updates...)
for _, ch := range extra.Chats {
dst.Chats = appendUniqueTGChats(dst.Chats, ch)
}
}
func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) *tg.Updates {
updates := r.channelStateUpdates(viewerUserID, channel)
// 母广播频道有/曾有关联 monoforum 时,按完整(非 min)形态下发。关闭 Direct Messages 时
// linked_monoforum_id 在投影里被隐藏,只有完整频道对象才能覆盖客户端缓存里旧的 linked_monoforum_id,
// 触发 applyMonoforumLinkedId(parent,0) → monoforum 的 MonoforumDisabled → 「已停用私信」停用页脚;
// min 母频道不覆盖缓存字段,页脚出不来。与官方 disable(parent 完整下发 + 隐藏 linked id)一致。
// 普通频道(无 linked monoforum)仍走 min,不受影响。
if channel.LinkedMonoforumID != 0 {
updates.Chats = []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}
}
if includeMono {
updates.Chats = appendUniqueTGChats(updates.Chats, tgChannelChat(viewerUserID, mono, nil))
}
return updates
}
func (r *Router) linkedMonoforumForChannelState(ctx context.Context, userID int64, channel domain.Channel) (domain.Channel, bool) {
if r.deps.Channels == nil || userID == 0 || !channel.BroadcastMessagesAllowed || channel.LinkedMonoforumID == 0 {
return domain.Channel{}, false
}
mono, err := r.deps.Channels.GetJoinableChannel(ctx, userID, channel.LinkedMonoforumID)
if err != nil || !mono.Monoforum || mono.LinkedMonoforumID != channel.ID {
return domain.Channel{}, false
}
return mono, true
}
func (r *Router) channelMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64, cache *viewerPeerCache) *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)
}
func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID int64, discussion *domain.SendChannelDiscussionResult) {
if discussion == nil || discussion.Channel.ID == 0 || discussion.Event.Pts == 0 {
return
}
res := domain.SendChannelMessageResult{
Channel: discussion.Channel,
Message: discussion.Message,
Event: discussion.Event,
Recipients: discussion.Recipients,
MentionUserIDs: discussion.MentionUserIDs,
}
// 讨论组联动broadcast↔linked megagroup的第二轮 fan-out 也异步化 + 跨 viewer 投影预热
// (设计 Phase 0/Phase 1。cache 仅被本 fan-out 闭包/预热使用、由单分片 worker 串行执行,
// 无跨 goroutine 竞态。
r.enqueueChannelMessageFanout(ctx, originUserID, res, nil)
}
func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64, cache *viewerPeerCache) *tg.Updates {
if cache == nil {
cache = newViewerPeerCache(r)
}
updates := make([]tg.UpdateClass, 0, len(results)*2)
userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs))
for _, id := range extraUserIDs {
if id != 0 {
userIDs[id] = struct{}{}
}
}
channelIDs := make(map[int64]struct{}, len(results))
var channel domain.Channel
date := 0
for i, res := range results {
if res.Channel.ID != 0 {
channel = res.Channel
}
if includeMessageIDs && res.Message.ID != 0 && i < len(randomIDs) && randomIDs[i] != 0 {
updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomIDs[i]})
}
if res.Event.Pts != 0 {
event := projectChannelMentionForViewer(res.Event, res.MentionUserIDs, viewerUserID)
if update := tgChannelUpdate(viewerUserID, event); update != nil {
updates = append(updates, update)
}
}
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
if date == 0 {
date = res.Event.Date
}
if date == 0 {
date = res.Message.Date
}
}
chats := []tg.ChatClass(nil)
if channel.ID != 0 {
chats = []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}
}
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), channel.ID)))...)
if date == 0 {
date = int(r.clock.Now().Unix())
}
return &tg.Updates{
Updates: updates,
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
Chats: chats,
Date: date,
Seq: 0,
}
}
func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult) *tg.Updates {
return r.channelEditMessageUpdatesWithPeerCache(ctx, viewerUserID, res, newViewerPeerCache(r))
}
func (r *Router) channelEditMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult, cache *viewerPeerCache) *tg.Updates {
if cache == nil {
cache = newViewerPeerCache(r)
}
updates := make([]tg.UpdateClass, 0, 2)
userIDs := make(map[int64]struct{}, 2)
channelIDs := make(map[int64]struct{})
if res.Event.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
updates = append(updates, update)
}
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
}
if res.ServiceEvent.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, res.ServiceEvent); update != nil {
updates = append(updates, update)
}
collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs)
}
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{
Updates: updates,
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))),
Chats: chats,
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func (r *Router) channelDeleteMessagesUpdates(viewerUserID int64, channel domain.Channel, event domain.ChannelUpdateEvent) *tg.Updates {
updates := make([]tg.UpdateClass, 0, 1)
if update := tgChannelUpdate(viewerUserID, event); update != nil {
updates = append(updates, update)
}
return &tg.Updates{
Updates: updates,
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func (r *Router) channelAvailableMessagesUpdates(viewerUserID int64, channel domain.Channel, availableMinID int) *tg.Updates {
updates := make([]tg.UpdateClass, 0, 1)
if channel.ID != 0 && availableMinID > 0 {
updates = append(updates, &tg.UpdateChannelAvailableMessages{
ChannelID: channel.ID,
AvailableMinID: availableMinID,
})
}
return &tg.Updates{
Updates: updates,
Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
// projectChannelMentionForViewer 把全局消息快照按接收者投影 viewer-specific
// 的 mentioned/media_unread被 @ 的在线成员必须在实时推送里就看到 @ 角标,
// 不能等到离线差量或重开会话才出现。
func projectChannelMentionForViewer(event domain.ChannelUpdateEvent, mentionUserIDs []int64, viewerUserID int64) domain.ChannelUpdateEvent {
if viewerUserID == 0 || event.Message.ID == 0 || viewerUserID == event.Message.SenderUserID {
return event
}
for _, id := range mentionUserIDs {
if id != viewerUserID {
continue
}
// 客户端的未读提及判定要求 mentioned 与 media_unread 同时置位,
// 与消息是否含媒体无关。
event.Message.Mentioned = true
event.Message.MediaUnread = true
return event
}
return event
}
type channelUpdatesBuilder func(viewerUserID int64) *tg.Updates
func (r *Router) pushChannelReadOutboxUpdates(ctx context.Context, channelID int64, updates []domain.ChannelReadOutboxUpdate) {
if r.deps.Sessions == nil || channelID == 0 || len(updates) == 0 {
return
}
seen := make(map[int64]int, len(updates))
for _, update := range updates {
if update.UserID == 0 || update.MaxID <= 0 {
continue
}
if seen[update.UserID] < update.MaxID {
seen[update.UserID] = update.MaxID
}
}
date := int(r.clock.Now().Unix())
for userID, maxID := range seen {
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateReadChannelOutbox{ChannelID: channelID, MaxID: maxID}},
Date: date,
Seq: 0,
})
}
}
func (r *Router) pushChannelUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
r.pushChannelUpdatesWithScope(ctx, channelFanoutMembers, originUserID, channelID, recipients, build)
}
func (r *Router) pushChannelViewerUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
r.pushChannelUpdatesWithScope(ctx, channelFanoutViewers, originUserID, channelID, recipients, build)
}
func (r *Router) pushChannelExplicitUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
r.pushChannelUpdatesWithScope(ctx, channelFanoutExplicit, originUserID, channelID, recipients, build)
}
func (r *Router) pushChannelUpdatesWithScope(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) {
if r.deps.Sessions == nil || build == nil {
return
}
explicit := recipients
recipients = r.channelFanoutRecipients(ctx, scope, channelID, recipients)
r.log.Debug("push channel updates fanout",
zap.Int64("channel_id", channelID),
zap.Int("scope", int(scope)),
zap.Int64("origin_user_id", originUserID),
zap.Int64s("explicit", explicit),
zap.Int64s("recipients", recipients),
)
seen := make(map[int64]struct{}, len(recipients))
pushed := false
for _, userID := range recipients {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
updates := build(userID)
if updates == nil {
continue
}
r.pushUserUpdates(ctx, userID, updates)
pushed = true
}
if !pushed && originUserID != 0 {
updates := build(originUserID)
if updates == nil {
return
}
r.pushUserUpdates(ctx, originUserID, updates)
}
}
func tgEmptyUpdates(date int) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
}

View file

@ -0,0 +1,748 @@
package rpc
import (
"context"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"strings"
appchannels "telesrv/internal/app/channels"
appupdates "telesrv/internal/app/updates"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
Title: "Fanout",
MemberUserIDs: []int64{1002, 1999},
Date: 10,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sessions := &captureSessions{
onlineUserIDs: []int64{1999, 3000},
channelViewers: map[int64][]int64{created.Channel.ID: {1999}},
channelMembers: map[int64][]int64{created.Channel.ID: {1999, 3000}},
}
r := New(Config{}, Deps{
Channels: channelService,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
contains := func(items []int64, want int64) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
got := r.channelFanoutRecipients(ctx, channelFanoutMembers, created.Channel.ID, []int64{1002})
if !contains(got, 1999) {
t.Fatalf("recipients = %v, want online active member 1999", got)
}
if contains(got, 3000) {
t.Fatalf("recipients = %v, non-member online user leaked", got)
}
if !contains(got, 1002) {
t.Fatalf("recipients = %v, want explicit fallback recipient 1002", got)
}
onlines, err := r.onMessagesGetOnlines(WithUserID(ctx, 1001), &tg.InputPeerChannel{
ChannelID: created.Channel.ID,
AccessHash: created.Channel.AccessHash,
})
if err != nil {
t.Fatalf("messages.getOnlines: %v", err)
}
if onlines.Onlines != 2 {
t.Fatalf("messages.getOnlines = %d, want caller plus online active member", onlines.Onlines)
}
}
func TestChannelSendHistoryAndDifferenceRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "RPC Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
var authKeyID [8]byte
authKeyID[0] = 9
sendCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), authKeyID), 77)
sent, err := r.onMessagesSendMessage(sendCtx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "hello channel",
RandomID: 99,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
sendUpdates := sent.(*tg.Updates)
if id, ok := sendUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 3 || id.RandomID != 99 {
t.Fatalf("message id update = %#v, want id=3 random_id=99", sendUpdates.Updates[0])
}
newMsg, ok := sendUpdates.Updates[1].(*tg.UpdateNewChannelMessage)
if !ok || newMsg.Pts != 3 || newMsg.PtsCount != 1 {
t.Fatalf("new channel update = %#v, want pts=3", sendUpdates.Updates[1])
}
msg := newMsg.Message.(*tg.Message)
if msg.PeerID.(*tg.PeerChannel).ChannelID != channel.ID || msg.Message != "hello channel" || !msg.Out {
t.Fatalf("channel message = %#v, want outgoing channel text", msg)
}
pushed := sessions.snapshot()
if pushed.userID != friend.ID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer {
t.Fatalf("pushed channel update = user %d exclude session %d type %v, want friend/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType)
}
if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID {
t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Updates) != 1 {
t.Fatalf("pushed channel update = %T %+v, want one updates container without updateMessageID", pushed.message, pushed.message)
}
pushedNew, ok := pushedUpdates.Updates[0].(*tg.UpdateNewChannelMessage)
if !ok {
t.Fatalf("pushed update[0] = %T, want updateNewChannelMessage", pushedUpdates.Updates[0])
}
pushedMsg := pushedNew.Message.(*tg.Message)
if pushedMsg.Out || pushedMsg.Message != "hello channel" {
t.Fatalf("pushed message = %#v, want incoming channel text for friend", pushedMsg)
}
history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []tg.InputMessageClass{&tg.InputMessageID{ID: msg.ID}},
})
if err != nil {
t.Fatalf("get channel messages: %v", err)
}
messages := history.(*tg.MessagesMessages)
got := messages.Messages[0].(*tg.Message)
if got.Message != "hello channel" || got.Out {
t.Fatalf("history message = %#v, want incoming text for friend", got)
}
contentAuthKeyID := [8]byte{0x44}
contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, friend.ID), contentAuthKeyID), 88)
if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{msg.ID},
}); err != nil || !ok {
t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err)
}
contentPush := sessions.snapshot()
if contentPush.userID != friend.ID || contentPush.sessionID != 88 || contentPush.messageType != proto.MessageFromServer {
t.Fatalf("content-read push = user %d exclude session %d type %v, want friend/exclude/from_server", contentPush.userID, contentPush.sessionID, contentPush.messageType)
}
if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID {
t.Fatalf("content-read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID)
}
contentUpdates, ok := contentPush.message.(*tg.Updates)
if !ok || len(contentUpdates.Updates) != 1 {
t.Fatalf("content-read pushed message = %T %+v, want one update", contentPush.message, contentPush.message)
}
contentRead, ok := contentUpdates.Updates[0].(*tg.UpdateChannelReadMessagesContents)
if !ok || contentRead.ChannelID != channel.ID || len(contentRead.Messages) != 1 || contentRead.Messages[0] != msg.ID {
t.Fatalf("content-read update = %#v, want channel %d msg %d", contentUpdates.Updates[0], channel.ID, msg.ID)
}
diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: newMsg.Pts - 1,
Limit: 10,
})
if err != nil {
t.Fatalf("channel difference: %v", err)
}
fullDiff, ok := diff.(*tg.UpdatesChannelDifference)
if !ok || fullDiff.Pts != newMsg.Pts || len(fullDiff.NewMessages) != 1 {
t.Fatalf("diff = %T %+v, want one new message at pts=%d", diff, diff, newMsg.Pts)
}
if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: fullDiff.Pts + 1,
Limit: 10,
}); err == nil || !strings.Contains(err.Error(), "PERSISTENT_TIMESTAMP_INVALID") {
t.Fatalf("future channel pts err = %v, want PERSISTENT_TIMESTAMP_INVALID", err)
}
readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID,
})
if err != nil || !readOK {
t.Fatalf("channels.readHistory = %v err %v, want true", readOK, err)
}
readPush := sessions.snapshot()
if readPush.userID != owner.ID || readPush.messageType != proto.MessageFromServer {
t.Fatalf("read outbox push = user %d type %v, want owner/from_server", readPush.userID, readPush.messageType)
}
readPushUpdates, ok := readPush.message.(*tg.Updates)
if !ok || len(readPushUpdates.Updates) != 1 {
t.Fatalf("read outbox pushed message = %T %+v, want one update", readPush.message, readPush.message)
}
readOutbox, ok := readPushUpdates.Updates[0].(*tg.UpdateReadChannelOutbox)
if !ok || readOutbox.ChannelID != channel.ID || readOutbox.MaxID != msg.ID {
t.Fatalf("read outbox update = %#v, want channel %d max %d", readPushUpdates.Updates[0], channel.ID, msg.ID)
}
fullAfterRead, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after read: %v", err)
}
fullChannel := fullAfterRead.FullChat.(*tg.ChannelFull)
if fullChannel.ReadOutboxMaxID != msg.ID {
t.Fatalf("full channel read_outbox = %d, want %d", fullChannel.ReadOutboxMaxID, msg.ID)
}
readers, err := r.onMessagesGetMessageReadParticipants(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReadParticipantsRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MsgID: msg.ID,
})
if err != nil {
t.Fatalf("get message read participants: %v", err)
}
if len(readers) != 1 || readers[0].UserID != friend.ID || readers[0].Date == 0 {
t.Fatalf("read participants = %+v, want friend read date", readers)
}
editReq := &tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: msg.ID,
Message: "edited channel",
}
editReq.SetMessage("edited channel")
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
if err != nil {
t.Fatalf("edit channel message: %v", err)
}
editUpdates := edited.(*tg.Updates)
edit, ok := editUpdates.Updates[0].(*tg.UpdateEditChannelMessage)
if !ok || edit.Pts != newMsg.Pts+1 || edit.PtsCount != 1 {
t.Fatalf("edit update = %#v, want updateEditChannelMessage pts=%d", editUpdates.Updates[0], newMsg.Pts+1)
}
if edit.Message.(*tg.Message).Message != "edited channel" {
t.Fatalf("edited message = %#v, want edited text", edit.Message)
}
editData, err := r.onMessagesGetMessageEditData(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageEditDataRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: msg.ID,
})
if err != nil {
t.Fatalf("get channel edit data: %v", err)
}
if editData.GetCaption() {
t.Fatalf("channel edit data caption = true, want false for text-only message")
}
forwardReplyTo := &tg.InputReplyToMessage{ReplyToMsgID: msg.ID}
forwardReplyTo.SetQuoteText("channel")
forwardReq := &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{msg.ID},
RandomID: []int64{100},
}
forwardReq.SetReplyTo(forwardReplyTo)
forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, friend.ID), forwardReq)
if err != nil {
t.Fatalf("forward channel message: %v", err)
}
forwardUpdates := forwarded.(*tg.Updates)
if id, ok := forwardUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != msg.ID+1 || id.RandomID != 100 {
t.Fatalf("forward id update = %#v, want id=%d", forwardUpdates.Updates[0], msg.ID+1)
}
forwardNew, ok := forwardUpdates.Updates[1].(*tg.UpdateNewChannelMessage)
if !ok || forwardNew.Pts != edit.Pts+1 || forwardNew.PtsCount != 1 {
t.Fatalf("forward new update = %#v, want pts=%d", forwardUpdates.Updates[1], edit.Pts+1)
}
forwardMsg := forwardNew.Message.(*tg.Message)
if forwardMsg.Message != "edited channel" || forwardMsg.FwdFrom.FromID == nil {
t.Fatalf("forward message = %#v, want fwd header and edited body", forwardMsg)
}
if header, ok := forwardMsg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != msg.ID {
t.Fatalf("forward reply header = %#v, want reply to channel message %d", forwardMsg.ReplyTo, msg.ID)
}
deleted, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{msg.ID, forwardMsg.ID},
})
if err != nil {
t.Fatalf("delete channel messages: %v", err)
}
if deleted.Pts != forwardNew.Pts+2 || deleted.PtsCount != 2 {
t.Fatalf("delete affected = %+v, want pts=%d count=2", deleted, forwardNew.Pts+2)
}
diff, err = r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: newMsg.Pts,
Limit: 10,
})
if err != nil {
t.Fatalf("channel difference after edit/delete: %v", err)
}
fullDiff, ok = diff.(*tg.UpdatesChannelDifference)
if !ok || fullDiff.Pts != deleted.Pts || len(fullDiff.NewMessages) != 1 || len(fullDiff.OtherUpdates) != 3 {
t.Fatalf("diff after edit/delete = %T %+v, want forward message id mapping plus edit/delete updates", diff, diff)
}
// 差量首条 other update 是请求者自己消息的 updateMessageID断线后
// 经差量对账本地 pending避免重复气泡。
if mapping, ok := fullDiff.OtherUpdates[0].(*tg.UpdateMessageID); !ok || mapping.RandomID == 0 || mapping.ID == 0 {
t.Fatalf("diff other[0] = %#v, want updateMessageID for own forwarded message", fullDiff.OtherUpdates[0])
}
}
func TestChannelsReadMessageContentsClearsUnreadReactionAndPushesUpdate(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Reaction Read",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "owner message",
RandomID: 21041,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
msgID := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message).ID
req := &tg.MessagesSendReactionRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MsgID: msgID,
Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f525"}},
}
req.SetReaction(req.Reaction)
if _, err := r.onMessagesSendReaction(WithUserID(ctx, friend.ID), req); err != nil {
t.Fatalf("friend send reaction: %v", err)
}
unread, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 10,
})
if err != nil {
t.Fatalf("get unread reactions: %v", err)
}
unreadMessages, _, _ := searchMessagesPayload(t, unread)
if len(unreadMessages) != 1 {
t.Fatalf("unread reactions = %+v, want one message", unread)
}
contentAuthKeyID := [8]byte{0x66}
contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), contentAuthKeyID), 99)
if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{msgID},
}); err != nil || !ok {
t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err)
}
pushed := sessions.snapshot()
if pushed.userID != owner.ID || pushed.sessionID != 99 || pushed.messageType != proto.MessageFromServer {
t.Fatalf("reaction read push = user %d session %d type %v, want owner/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType)
}
if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID {
t.Fatalf("reaction read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Updates) != 1 {
t.Fatalf("reaction read push = %T %+v, want one updateMessageReactions", pushed.message, pushed.message)
}
reactionUpdate, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions)
if !ok || reactionUpdate.MsgID != msgID {
t.Fatalf("reaction read update = %#v, want updateMessageReactions for %d", pushedUpdates.Updates[0], msgID)
}
for _, recent := range reactionUpdate.Reactions.RecentReactions {
if recent.Unread {
t.Fatalf("reaction read update recent = %+v, want unread cleared", reactionUpdate.Reactions.RecentReactions)
}
}
unreadAfter, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 10,
})
if err != nil {
t.Fatalf("get unread reactions after read contents: %v", err)
}
unreadAfterMessages, _, _ := searchMessagesPayload(t, unreadAfter)
if len(unreadAfterMessages) != 0 {
t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter)
}
}
func TestChannelReadHistoryProducesReadChannelInboxDifference(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002131", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002132", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
updates := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Updates: updates,
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Read Channel Inbox",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "read me",
RandomID: 301,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
newUpdate := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
msg := newUpdate.Message.(*tg.Message)
readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID,
})
if err != nil {
t.Fatalf("channels.readHistory: %v", err)
}
if !readOK {
t.Fatalf("channels.readHistory = false, want true")
}
diff, err := r.onUpdatesGetDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0})
if err != nil {
t.Fatalf("updates.getDifference: %v", err)
}
full, ok := diff.(*tg.UpdatesDifference)
if !ok || len(full.OtherUpdates) != 1 {
t.Fatalf("difference = %T %+v, want one read channel inbox update", diff, diff)
}
read, ok := full.OtherUpdates[0].(*tg.UpdateReadChannelInbox)
if !ok || read.ChannelID != channel.ID || read.MaxID != msg.ID || read.StillUnreadCount != 0 {
t.Fatalf("difference update = %#v, want updateReadChannelInbox channel %d max %d", full.OtherUpdates[0], channel.ID, msg.ID)
}
if read.Pts != newUpdate.Pts {
t.Fatalf("difference channel read pts = %d, want channel pts %d", read.Pts, newUpdate.Pts)
}
if len(full.Chats) != 1 {
t.Fatalf("difference chats = %d, want channel context", len(full.Chats))
}
}
func TestChannelDifferenceTooLongCarriesDialogPts(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "RPC TooLong Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sourceCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "RPC TooLong Source",
About: "forward source",
Broadcast: true,
})
if err != nil {
t.Fatalf("create source channel: %v", err)
}
sourceChannel := sourceCreated.(*tg.Updates).Chats[0].(*tg.Channel)
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
}); err != nil {
t.Fatalf("invite friend to source channel: %v", err)
}
sourceSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash},
Message: "forward source",
RandomID: 7000,
})
if err != nil {
t.Fatalf("send source channel message: %v", err)
}
sourceMsgID := sourceSent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash},
ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{sourceMsgID},
RandomID: []int64{7001},
}); err != nil {
t.Fatalf("forward source channel to target channel: %v", err)
}
for i := 0; i < 12; i++ {
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "too long page",
RandomID: int64(i + 1),
}); err != nil {
t.Fatalf("send channel message %d: %v", i, err)
}
}
diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: 0,
Limit: 3,
})
if err != nil {
t.Fatalf("channel difference: %v", err)
}
tooLong, ok := diff.(*tg.UpdatesChannelDifferenceTooLong)
if !ok {
t.Fatalf("diff = %T %+v, want channelDifferenceTooLong", diff, diff)
}
dialog, ok := tooLong.Dialog.(*tg.Dialog)
if !ok {
t.Fatalf("tooLong dialog = %T, want dialog", tooLong.Dialog)
}
pts, ok := dialog.GetPts()
if !ok || pts == 0 {
t.Fatalf("tooLong dialog pts = %d ok=%v, want current channel pts", pts, ok)
}
if len(tooLong.Messages) == 0 || len(tooLong.Messages) > domain.MaxChannelDifferenceTooLongMessages {
t.Fatalf("tooLong messages = %d, want bounded latest snapshot", len(tooLong.Messages))
}
if len(tooLong.Chats) == 0 || tooLong.Chats[0].(*tg.Channel).ID != channel.ID {
t.Fatalf("tooLong chats = %+v, want source channel context", tooLong.Chats)
}
hasSourceChannel := false
for _, chat := range tooLong.Chats {
if ch, ok := chat.(*tg.Channel); ok && ch.ID == sourceChannel.ID {
hasSourceChannel = true
break
}
}
if !hasSourceChannel {
t.Fatalf("tooLong chats = %+v, want forwarded source channel context %d", tooLong.Chats, sourceChannel.ID)
}
hasOwnerUser := false
for _, user := range tooLong.Users {
if u, ok := user.(*tg.User); ok && u.ID == owner.ID {
hasOwnerUser = true
break
}
}
if !hasOwnerUser {
t.Fatalf("tooLong users = %+v, want sender user context", tooLong.Users)
}
}
func TestChannelUnreadMentionsRPCUsesMentionState(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner", Username: "owner_mention"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Mentioned", Username: "mention_friend"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
}, zaptest.NewLogger(t), clock.System)
created, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Mention RPC",
MemberUserIDs: []int64{member.ID},
Date: 1700009101,
})
if err != nil {
t.Fatalf("create megagroup: %v", err)
}
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: peer,
Message: "hello @mention_friend",
RandomID: 9102001,
}); err != nil {
t.Fatalf("send mention: %v", err)
}
mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{
Peer: peer,
OffsetID: 1,
AddOffset: -10,
Limit: 10,
})
if err != nil {
t.Fatalf("messages.getUnreadMentions: %v", err)
}
mentionMessages, _, _ := searchMessagesPayload(t, mentions)
if len(mentionMessages) != 1 {
t.Fatalf("messages.getUnreadMentions = %T %+v, want one unread mention", mentions, mentions)
}
if msg := mentionMessages[0].(*tg.Message); msg.Message != "hello @mention_friend" {
t.Fatalf("mention message = %#v, want sent mention", msg)
}
read, err := r.onMessagesReadMentions(WithUserID(ctx, member.ID), &tg.MessagesReadMentionsRequest{Peer: peer})
if err != nil {
t.Fatalf("messages.readMentions: %v", err)
}
if read.Pts <= 0 || read.PtsCount != 0 || read.Offset != 0 {
t.Fatalf("messages.readMentions = %+v, want current channel pts and no offset", read)
}
mentions, err = r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{
Peer: peer,
Limit: 10,
})
if err != nil {
t.Fatalf("messages.getUnreadMentions after read: %v", err)
}
mentionMessages, _, _ = searchMessagesPayload(t, mentions)
if got := len(mentionMessages); got != 0 {
t.Fatalf("unread mentions after read = %d, want 0", got)
}
}
func TestChannelDifferenceIncludesExtraForwardSourceChannel(t *testing.T) {
channel := domain.Channel{ID: 2000000100, AccessHash: 9010, Title: "Megagroup", Megagroup: true, Date: 1700000000, Pts: 3}
source := domain.Channel{ID: 2000000101, AccessHash: 9011, Title: "Source", Broadcast: true, Date: 1700000000}
got, ok := tgChannelDifference(1000000001, domain.ChannelDifference{
Channel: channel,
NewMessages: []domain.ChannelMessage{{
ChannelID: channel.ID,
ID: 3,
SenderUserID: 1000000002,
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
Date: 1700000103,
Body: "forwarded",
Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000},
Pts: 3,
}},
Users: []domain.User{{ID: 1000000002, AccessHash: 42, FirstName: "Bob"}},
Channels: []domain.Channel{source},
Pts: 3,
Final: true,
Timeout: 30,
}).(*tg.UpdatesChannelDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesChannelDifference", got)
}
if len(got.Users) != 1 || len(got.Chats) != 2 {
t.Fatalf("difference users/chats = %d/%d, want 1/2", len(got.Users), len(got.Chats))
}
if ch, ok := got.Chats[1].(*tg.Channel); !ok || ch.ID != source.ID {
t.Fatalf("extra chat = %#v, want source channel", got.Chats[1])
}
}
func TestChannelReadHistoryWithReliableDispatchPushesCurrentSessionReadUpdate(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 10
updates := &captureUpdates{
state: domain.UpdateState{Pts: 900, Date: 1700000102, Seq: 3},
reliableDispatch: true,
}
sessions := &captureSessions{}
r := New(Config{}, Deps{Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithAuthKeyID(context.Background(), authKeyID), 77)
recorded, err := r.recordChannelReadInbox(ctx, 1000000001, domain.ReadChannelHistoryResult{
ChannelID: 12345,
MaxID: 27,
StillUnreadCount: 2,
Changed: true,
Pts: 42,
})
if err != nil {
t.Fatalf("record channel read inbox: %v", err)
}
if recorded.Pts != 900 || recorded.ChannelPts != 42 || updates.excludeSessionID != 77 {
t.Fatalf("recorded event pts/channel_pts/session = %d/%d/%d, want durable pts 900, channel pts 42 and exclude session 77", recorded.Pts, recorded.ChannelPts, updates.excludeSessionID)
}
snap := sessions.snapshot()
if snap.sessionID != 77 || snap.messageType != proto.MessageFromServer {
t.Fatalf("current-session push target = session %d type %v, want session 77 server message", snap.sessionID, snap.messageType)
}
updatesMsg, ok := snap.message.(*tg.Updates)
if !ok || len(updatesMsg.Updates) != 2 {
t.Fatalf("current-session push = %T %+v, want read update plus account pts bookkeeping", snap.message, snap.message)
}
update, ok := updatesMsg.Updates[0].(*tg.UpdateReadChannelInbox)
if !ok {
t.Fatalf("current-session update = %T, want *tg.UpdateReadChannelInbox", updatesMsg.Updates[0])
}
if update.ChannelID != 12345 || update.Pts != 42 || update.MaxID != 27 || update.StillUnreadCount != 2 {
t.Fatalf("current-session channel read update = %+v, want channel=12345 pts=42 max=27 still=2", update)
}
bookkeeping, ok := updatesMsg.Updates[1].(*tg.UpdateDeleteMessages)
if !ok || len(bookkeeping.Messages) != 0 || bookkeeping.Pts != 900 || bookkeeping.PtsCount != 1 {
t.Fatalf("current-session bookkeeping = %#v, want empty updateDeleteMessages at account pts 900", updatesMsg.Updates[1])
}
}
func TestProjectChannelMentionForViewer(t *testing.T) {
event := domain.ChannelUpdateEvent{
ChannelID: 500,
Pts: 7,
PtsCount: 1,
Message: domain.ChannelMessage{
ChannelID: 500,
ID: 42,
SenderUserID: 1001,
Body: "hi @bob",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9}},
},
}
mentioned := projectChannelMentionForViewer(event, []int64{1002}, 1002)
if !mentioned.Message.Mentioned || !mentioned.Message.MediaUnread {
t.Fatalf("mentioned viewer = %+v, want mentioned+media_unread set in realtime push", mentioned.Message)
}
other := projectChannelMentionForViewer(event, []int64{1002}, 1003)
if other.Message.Mentioned || other.Message.MediaUnread {
t.Fatalf("other viewer = %+v, must not inherit mention flags", other.Message)
}
sender := projectChannelMentionForViewer(event, []int64{1001}, 1001)
if sender.Message.Mentioned {
t.Fatalf("sender = %+v, must not be marked mentioned by own message", sender.Message)
}
if event.Message.Mentioned {
t.Fatalf("source event mutated: projection must copy, not alias")
}
}

View file

@ -0,0 +1,263 @@
package rpc
import (
"context"
"strings"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
accountapp "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestQuickReplyRPCSaveListAndDeleteMessage(t *testing.T) {
const userID int64 = 1000000001
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), [8]byte{1}), 77)
r, updates := newChatAutomationTestRouter(t)
got, err := r.onMessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "Saved template",
RandomID: 12345,
QuickReplyShortcut: &tg.InputQuickReplyShortcut{Shortcut: "hello"},
})
if err != nil {
t.Fatalf("onMessagesSendMessage quick reply: %v", err)
}
result, ok := got.(*tg.Updates)
if !ok {
t.Fatalf("result type = %T, want *tg.Updates", got)
}
var messageID int
var shortcutID int
var sawNew bool
var sawMessageID bool
for _, update := range result.Updates {
switch u := update.(type) {
case *tg.UpdateMessageID:
sawMessageID = true
messageID = u.ID
if u.RandomID != 12345 {
t.Fatalf("UpdateMessageID random_id = %d", u.RandomID)
}
case *tg.UpdateNewQuickReply:
sawNew = true
shortcutID = u.QuickReply.ShortcutID
if u.QuickReply.Shortcut != "hello" {
t.Fatalf("UpdateNewQuickReply shortcut = %q", u.QuickReply.Shortcut)
}
}
}
if !sawMessageID || !sawNew || messageID == 0 || shortcutID == 0 {
t.Fatalf("updates = %#v, want updateMessageID and updateNewQuickReply", result.Updates)
}
if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventNewQuickReply {
t.Fatalf("recorded events = %+v", updates.events)
}
list, err := r.onMessagesGetQuickReplies(ctx, 0)
if err != nil {
t.Fatalf("onMessagesGetQuickReplies: %v", err)
}
replies, ok := list.(*tg.MessagesQuickReplies)
if !ok || len(replies.QuickReplies) != 1 || len(replies.Messages) != 1 {
t.Fatalf("quick replies = %#v", list)
}
deleted, err := r.onMessagesDeleteQuickReplyMessages(ctx, &tg.MessagesDeleteQuickReplyMessagesRequest{
ShortcutID: shortcutID,
ID: []int{messageID},
})
if err != nil {
t.Fatalf("onMessagesDeleteQuickReplyMessages: %v", err)
}
deleteUpdates, ok := deleted.(*tg.Updates)
if !ok {
t.Fatalf("delete result type = %T", deleted)
}
var sawDelete bool
for _, update := range deleteUpdates.Updates {
if u, ok := update.(*tg.UpdateDeleteQuickReplyMessages); ok {
sawDelete = true
if u.ShortcutID != shortcutID || len(u.Messages) != 1 || u.Messages[0] != messageID {
t.Fatalf("delete update = %+v", u)
}
}
}
if !sawDelete {
t.Fatalf("delete updates = %#v, want updateDeleteQuickReplyMessages", deleteUpdates.Updates)
}
}
func TestBusinessChatLinkRPCs(t *testing.T) {
const userID int64 = 1000000002
ctx := WithUserID(context.Background(), userID)
r, _ := newChatAutomationTestRouter(t)
created, err := r.onAccountCreateBusinessChatLink(ctx, tg.InputBusinessChatLink{
Message: "Prefilled message",
Title: "Support",
})
if err != nil {
t.Fatalf("onAccountCreateBusinessChatLink: %v", err)
}
if created.Link == "" || created.Message != "Prefilled message" {
t.Fatalf("created link = %+v", created)
}
slug := strings.TrimPrefix(created.Link, "https://telesrv.net/m/")
if slug == created.Link || slug == "" {
t.Fatalf("created link URL = %q, want telesrv.net/m slug", created.Link)
}
list, err := r.onAccountGetBusinessChatLinks(ctx)
if err != nil || len(list.Links) != 1 {
t.Fatalf("onAccountGetBusinessChatLinks len=%d err=%v", len(list.Links), err)
}
resolved, err := r.onAccountResolveBusinessChatLink(ctx, slug)
if err != nil {
t.Fatalf("onAccountResolveBusinessChatLink: %v", err)
}
peer, ok := resolved.Peer.(*tg.PeerUser)
if !ok || peer.UserID != userID || resolved.Message != "Prefilled message" {
t.Fatalf("resolved = %+v", resolved)
}
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)
}
if deleted, err := r.onAccountDeleteBusinessChatLink(ctx, slug); err != nil || !deleted {
t.Fatalf("onAccountDeleteBusinessChatLink deleted=%v err=%v", deleted, err)
}
}
func TestConnectedBusinessBotRPCFlow(t *testing.T) {
const ownerID int64 = 1000000010
const peerID int64 = 1000000011
const botID int64 = 1000000012
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), [8]byte{2}), 88)
store := memory.NewPasswordStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 20, Date: 1700000000}}
users := mapUsersService{users: map[int64]domain.User{
ownerID: {ID: ownerID, AccessHash: 101, FirstName: "Bob"},
peerID: {ID: peerID, AccessHash: 102, FirstName: "Alice"},
botID: {ID: botID, AccessHash: 103, FirstName: "Echo", Username: "echo_test_bot", Bot: true, BotInfoVersion: 1},
}}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Account: accountapp.NewService(store, accountapp.WithBusinessAutomation(store)),
Users: users,
Updates: updates,
}, zaptest.NewLogger(t), clock.System)
updateReq := &tg.AccountUpdateConnectedBotRequest{
Bot: &tg.InputUser{UserID: botID, AccessHash: 103},
Recipients: tg.InputBusinessBotRecipients{ExcludeSelected: true},
}
updateReq.SetRights(tg.BusinessBotRights{Reply: true})
if _, err := r.onAccountUpdateConnectedBot(ctx, updateReq); err != nil {
t.Fatalf("onAccountUpdateConnectedBot: %v", err)
}
connected, err := r.onAccountGetConnectedBots(ctx)
if err != nil {
t.Fatalf("onAccountGetConnectedBots: %v", err)
}
if len(connected.ConnectedBots) != 1 || connected.ConnectedBots[0].BotID != botID || !connected.ConnectedBots[0].Rights.Reply {
t.Fatalf("connected bots = %+v", connected.ConnectedBots)
}
botUser, ok := connected.Users[0].(*tg.User)
if !ok || !botUser.Bot || !botUser.BotBusiness {
t.Fatalf("connected bot user = %#v, want bot_business", connected.Users[0])
}
peerSettings, err := r.onMessagesGetPeerSettings(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102})
if err != nil {
t.Fatalf("onMessagesGetPeerSettings: %v", err)
}
if peerSettings.Settings.BusinessBotID != botID || !peerSettings.Settings.BusinessBotCanReply || peerSettings.Settings.BusinessBotPaused {
t.Fatalf("peer settings before pause = %+v", peerSettings.Settings)
}
if ok, err := r.onAccountToggleConnectedBotPaused(ctx, &tg.AccountToggleConnectedBotPausedRequest{
Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 102},
Paused: true,
}); err != nil || !ok {
t.Fatalf("onAccountToggleConnectedBotPaused = %v,%v", ok, err)
}
peerSettings, err = r.onMessagesGetPeerSettings(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102})
if err != nil {
t.Fatalf("onMessagesGetPeerSettings paused: %v", err)
}
if peerSettings.Settings.BusinessBotID != botID || !peerSettings.Settings.BusinessBotPaused || peerSettings.Settings.BusinessBotCanReply {
t.Fatalf("peer settings paused = %+v", peerSettings.Settings)
}
if ok, err := r.onAccountDisablePeerConnectedBot(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102}); err != nil || !ok {
t.Fatalf("onAccountDisablePeerConnectedBot = %v,%v", ok, err)
}
peerSettings, err = r.onMessagesGetPeerSettings(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102})
if err != nil {
t.Fatalf("onMessagesGetPeerSettings disabled: %v", err)
}
if peerSettings.Settings.BusinessBotID != 0 || peerSettings.Settings.BusinessBotCanReply || peerSettings.Settings.BusinessBotPaused {
t.Fatalf("peer settings disabled = %+v", peerSettings.Settings)
}
}
func TestConnectedBusinessBotDefaultsMissingRightsToReply(t *testing.T) {
const ownerID int64 = 1000000110
const peerID int64 = 1000000111
const botID int64 = 1000000112
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), [8]byte{3}), 89)
store := memory.NewPasswordStore()
users := mapUsersService{users: map[int64]domain.User{
ownerID: {ID: ownerID, AccessHash: 101, FirstName: "Bob"},
peerID: {ID: peerID, AccessHash: 102, FirstName: "Alice"},
botID: {ID: botID, AccessHash: 103, FirstName: "Echo", Username: "echo_default_bot", Bot: true, BotInfoVersion: 1},
}}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Account: accountapp.NewService(store, accountapp.WithBusinessAutomation(store)),
Users: users,
}, zaptest.NewLogger(t), clock.System)
if _, err := r.onAccountUpdateConnectedBot(ctx, &tg.AccountUpdateConnectedBotRequest{
Bot: &tg.InputUser{UserID: botID, AccessHash: 103},
Recipients: tg.InputBusinessBotRecipients{ExcludeSelected: true},
}); err != nil {
t.Fatalf("onAccountUpdateConnectedBot missing rights: %v", err)
}
connected, err := r.onAccountGetConnectedBots(ctx)
if err != nil {
t.Fatalf("onAccountGetConnectedBots: %v", err)
}
if len(connected.ConnectedBots) != 1 || !connected.ConnectedBots[0].Rights.Reply {
t.Fatalf("missing rights connected bots = %+v, want reply default", connected.ConnectedBots)
}
explicitEmpty := &tg.AccountUpdateConnectedBotRequest{
Bot: &tg.InputUser{UserID: botID, AccessHash: 103},
Recipients: tg.InputBusinessBotRecipients{ExcludeSelected: true},
}
explicitEmpty.SetRights(tg.BusinessBotRights{})
if _, err := r.onAccountUpdateConnectedBot(ctx, explicitEmpty); err != nil {
t.Fatalf("onAccountUpdateConnectedBot explicit empty rights: %v", err)
}
connected, err = r.onAccountGetConnectedBots(ctx)
if err != nil {
t.Fatalf("onAccountGetConnectedBots explicit empty: %v", err)
}
if len(connected.ConnectedBots) != 1 || connected.ConnectedBots[0].Rights.Reply {
t.Fatalf("explicit empty rights connected bots = %+v, want reply disabled", connected.ConnectedBots)
}
}
func newChatAutomationTestRouter(t *testing.T) (*Router, *captureUpdates) {
t.Helper()
store := memory.NewPasswordStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 10, Date: 1700000000}}
return New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Account: accountapp.NewService(store, accountapp.WithBusinessAutomation(store)),
Updates: updates,
}, zaptest.NewLogger(t), clock.System), updates
}

View file

@ -1,56 +1,21 @@
package rpc
import (
"context"
"fmt"
"time"
import "context"
"go.uber.org/zap"
"github.com/gotd/td/bin"
)
// dispatchCompat handles explicitly allowlisted legacy TL constructors that are
// still emitted by supported clients but are absent from gotd's pinned schema.
func (r *Router) dispatchCompat(ctx context.Context, b *bin.Buffer, id uint32) (bin.Encoder, bool, error) {
start := time.Now()
var (
enc bin.Encoder
name string
err error
)
switch id {
case legacyAccountRegisterDeviceTypeID:
name = "account.registerDevice#637ea878"
enc, err = r.handleLegacyAccountRegisterDevice(ctx, b)
case legacyUpdatesGetDifferenceTypeID:
name = "updates.getDifference#25939651"
enc, err = r.handleLegacyUpdatesGetDifference(ctx, b)
case legacyLangpackGetLangPackTypeID:
name = "langpack.getLangPack#9ab5c58e"
enc, err = r.handleLegacyLangpackGetLangPack(ctx, b)
case legacyLangpackGetStringsTypeID:
name = "langpack.getStrings#2e1ee318"
enc, err = r.handleLegacyLangpackGetStrings(ctx, b)
case legacyLangpackGetLanguagesTypeID:
name = "langpack.getLanguages#800fd57d"
enc, err = r.handleLegacyLangpackGetLanguages(ctx, b)
default:
return nil, false, nil
// withAndroidCompatMetadata 为「客户端构造器漂移」请求**仅在当前 ctx 内**兜底 client 层/类型。
// 这类请求多来自未完整走 initConnection 的 DrKLO Android当 layer/ClientType 仍未知时
// 按 android 处理使下游createChat 的 legacy 响应、langpack 的 lang_pack 派生)行为正确。
//
// 关键:**绝不把这个兜底值写回持久缓存**(不调 rememberClientLayer/rememberClientInfo
// 缓存是 invokeWithLayer/initConnection 的权威产物;条目被驱逐时该兜底会拿不到真实值而误判
// 227/android若写回就把长连接老客户端的真实 layer/类型永久覆盖(与 NegotiatedLayer 的
// 「驱逐时不覆盖」契约矛盾)。出站 layer 由 Conn.clientLayer 承载(非覆盖),与本兜底无关。
func (r *Router) withAndroidCompatMetadata(ctx context.Context) context.Context {
if LayerFrom(ctx) == 0 {
ctx = WithLayer(ctx, currentClientLayer)
}
fields := append([]zap.Field{
zap.String("method", name),
zap.String("type_id", fmt.Sprintf("%#x", id)),
zap.Bool("compat", true),
zap.Duration("dur", time.Since(start)),
}, r.contextLogFields(ctx)...)
if err != nil {
fields = append(fields, zap.Error(err))
r.log.Info("RPC compat handled", fields...)
} else {
r.log.Debug("RPC compat handled", fields...)
if ClientTypeFrom(ctx) == ClientTypeUnknown {
ctx = WithClientInfo(ctx, ClientInfo{LangPack: string(ClientTypeAndroid), Type: ClientTypeAndroid})
}
return enc, true, err
return ctx
}

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"errors"
"sort"
"strings"
"unicode/utf8"
@ -22,6 +23,8 @@ const (
maxContactNoteLength = 4096
maxContactSearchQLen = 256
maxContactSearchLimit = 50
maxCloseFriendsCount = 5000
maxContactSetBlocked = 5000
)
// registerContacts 注册 contacts.* RPC handler。
@ -33,8 +36,10 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
d.OnContactsAddContact(r.onContactsAddContact)
d.OnContactsAcceptContact(r.onContactsAcceptContact)
d.OnContactsDeleteContacts(r.onContactsDeleteContacts)
d.OnContactsEditCloseFriends(r.onContactsEditCloseFriends)
d.OnContactsBlock(r.onContactsBlock)
d.OnContactsUnblock(r.onContactsUnblock)
d.OnContactsSetBlocked(r.onContactsSetBlocked)
d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote)
d.OnContactsSearch(r.onContactsSearch)
d.OnContactsResolveUsername(r.onContactsResolveUsername)
@ -57,6 +62,173 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
})
}
func (r *Router) onContactsEditCloseFriends(ctx context.Context, id []int64) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if len(id) > maxCloseFriendsCount {
return false, limitInvalidErr()
}
if r.deps.Contacts == nil {
return true, nil
}
result, err := r.deps.Contacts.EditCloseFriends(ctx, userID, id)
if err != nil {
return false, contactErr(err)
}
if err := r.fanoutCloseFriendStoryChanges(ctx, userID, result); err != nil {
return false, err
}
return true, nil
}
func (r *Router) fanoutCloseFriendStoryChanges(ctx context.Context, ownerID int64, result domain.CloseFriendsEditResult) error {
if ownerID == 0 || r.deps.Stories == nil || r.deps.Updates == nil {
return nil
}
if len(result.AddedUserIDs) == 0 && len(result.RemovedUserIDs) == 0 {
return nil
}
candidateIDs := make([]int64, 0, len(result.AddedUserIDs)+len(result.RemovedUserIDs))
candidateIDs = append(candidateIDs, result.AddedUserIDs...)
candidateIDs = append(candidateIDs, result.RemovedUserIDs...)
blockedFacts, err := r.storyBlockedFactsForUsers(ctx, ownerID, candidateIDs)
if err != nil {
return err
}
owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
list, err := r.deps.Stories.ListOwnerActiveStories(ctx, ownerID, owner, int(r.clock.Now().Unix()), domain.MaxStoryListLimit)
if err != nil {
return storyErr(err)
}
for _, story := range list.Stories {
if !story.CloseFriends {
continue
}
story = storyFanoutSnapshot(story)
for _, userID := range result.AddedUserIDs {
if blockedFacts[userID] {
continue
}
if story.VisibleToWithFacts(userID, true, true) {
if err := r.recordStoryFanout(ctx, userID, story); err != nil {
return err
}
}
}
for _, userID := range result.RemovedUserIDs {
if blockedFacts[userID] || story.VisibleToWithFacts(userID, true, false) {
continue
}
deleted := story
deleted.Deleted = true
if err := r.recordStoryFanout(ctx, userID, deleted); err != nil {
return err
}
}
}
return nil
}
func (r *Router) storyBlockedFactsForUsers(ctx context.Context, ownerID int64, userIDs []int64) (map[int64]bool, error) {
out := make(map[int64]bool)
if ownerID == 0 || r.deps.Contacts == nil {
return out, nil
}
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 || userID == ownerID {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
blocked, err := r.deps.Contacts.IsBlocked(ctx, ownerID, userID)
if err != nil {
return nil, internalErr()
}
if blocked {
out[userID] = true
}
}
return out, nil
}
func (r *Router) fanoutStoryBlocklistChange(ctx context.Context, ownerID, viewerID int64, blocked bool) error {
if ownerID == 0 || viewerID == 0 || ownerID == viewerID || r.deps.Stories == nil || r.deps.Updates == nil {
return nil
}
facts, err := r.storyViewerFactsForOwner(ctx, ownerID, viewerID)
if err != nil {
return err
}
owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
now := int(r.clock.Now().Unix())
list, err := r.deps.Stories.ListOwnerActiveStories(ctx, ownerID, owner, now, domain.MaxStoryListLimit)
if err != nil {
return storyErr(err)
}
for _, story := range list.Stories {
if !story.Active(now) {
continue
}
beforeVisible := story.VisibleToWithStoryFacts(viewerID, facts.isContact, facts.closeFriend, !blocked)
afterVisible := story.VisibleToWithStoryFacts(viewerID, facts.isContact, facts.closeFriend, blocked)
switch {
case afterVisible && !beforeVisible:
if err := r.recordStoryFanout(ctx, viewerID, storyFanoutSnapshot(story)); err != nil {
return err
}
case beforeVisible && !afterVisible:
deleted := storyFanoutSnapshot(story)
deleted.Deleted = true
if err := r.recordStoryFanout(ctx, viewerID, deleted); err != nil {
return err
}
}
}
return nil
}
func (r *Router) storyViewerFactsForOwner(ctx context.Context, ownerID, viewerID int64) (storyPrivacyFanoutFacts, error) {
if ownerID == 0 || viewerID == 0 || r.deps.Contacts == nil {
return storyPrivacyFanoutFacts{}, nil
}
list, _, err := r.deps.Contacts.GetContacts(ctx, ownerID, 0)
if err != nil {
return storyPrivacyFanoutFacts{}, internalErr()
}
for _, contact := range list.Contacts {
if contact.User.ID != viewerID {
continue
}
return storyPrivacyFanoutFacts{
isContact: true,
closeFriend: contact.CloseFriend || contact.User.CloseFriend,
}, nil
}
return storyPrivacyFanoutFacts{}, nil
}
func (r *Router) recordStoryFanout(ctx context.Context, userID int64, story domain.Story) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
if _, _, err := r.deps.Updates.RecordStoryFanout(ctx, userID, story); err != nil {
return internalErr()
}
return nil
}
func storyFanoutSnapshot(story domain.Story) domain.Story {
story.Out = false
story.Views = domain.StoryViews{}
story.SentReaction = nil
return story
}
func (r *Router) onContactsBlock(ctx context.Context, req *tg.ContactsBlockRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -69,12 +241,25 @@ func (r *Router) onContactsBlock(ctx context.Context, req *tg.ContactsBlockReque
if r.deps.Contacts == nil {
return true, nil
}
wasBlocked, err := r.deps.Contacts.IsBlocked(ctx, userID, peer.ID)
if err != nil {
return false, contactErr(err)
}
if _, err := r.deps.Contacts.BlockContact(ctx, userID, peer.ID, int(r.clock.Now().Unix())); err != nil {
return false, contactErr(err)
}
if !wasBlocked {
if err := r.recordPeerStoryBlocked(ctx, userID, peer, true); err != nil {
return false, internalErr()
}
if err := r.fanoutStoryBlocklistChange(ctx, userID, peer.ID, true); err != nil {
return false, err
}
}
if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil {
_ = r.recordPeerSettings(ctx, userID, peer, settings)
}
r.invalidateRPCProjectionForViewer(userID)
return true, nil
}
@ -90,15 +275,137 @@ func (r *Router) onContactsUnblock(ctx context.Context, req *tg.ContactsUnblockR
if r.deps.Contacts == nil {
return true, nil
}
wasBlocked, err := r.deps.Contacts.IsBlocked(ctx, userID, peer.ID)
if err != nil {
return false, contactErr(err)
}
if _, err := r.deps.Contacts.UnblockContact(ctx, userID, peer.ID); err != nil {
return false, contactErr(err)
}
if wasBlocked {
if err := r.recordPeerStoryBlocked(ctx, userID, peer, false); err != nil {
return false, internalErr()
}
if err := r.fanoutStoryBlocklistChange(ctx, userID, peer.ID, false); err != nil {
return false, err
}
}
if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil {
_ = r.recordPeerSettings(ctx, userID, peer, settings)
}
r.invalidateRPCProjectionForViewer(userID)
return true, nil
}
func (r *Router) onContactsSetBlocked(ctx context.Context, req *tg.ContactsSetBlockedRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if len(req.ID) > maxContactSetBlocked || req.Limit < 0 || req.Limit > maxContactSetBlocked {
return false, limitInvalidErr()
}
if r.deps.Contacts == nil {
return true, nil
}
desired := make(map[int64]domain.Peer, len(req.ID))
desiredOrder := make([]domain.Peer, 0, len(req.ID))
for _, input := range req.ID {
peer, ok := r.domainPeerFromInputPeer(userID, input)
if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
return false, userIDInvalidErr()
}
if _, ok := desired[peer.ID]; ok {
continue
}
desired[peer.ID] = peer
desiredOrder = append(desiredOrder, peer)
}
current, err := r.currentBlockedUserIDs(ctx, userID)
if err != nil {
return false, err
}
now := int(r.clock.Now().Unix())
for _, peer := range desiredOrder {
if current[peer.ID] {
continue
}
changed, err := r.deps.Contacts.BlockContact(ctx, userID, peer.ID, now)
if err != nil {
return false, contactErr(err)
}
if changed {
if err := r.applyContactBlocklistChange(ctx, userID, peer, true); err != nil {
return false, err
}
}
}
removeIDs := make([]int64, 0, len(current))
for id := range current {
if _, ok := desired[id]; ok {
continue
}
removeIDs = append(removeIDs, id)
}
sort.Slice(removeIDs, func(i, j int) bool { return removeIDs[i] < removeIDs[j] })
for _, id := range removeIDs {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: id}
changed, err := r.deps.Contacts.UnblockContact(ctx, userID, id)
if err != nil {
return false, contactErr(err)
}
if changed {
if err := r.applyContactBlocklistChange(ctx, userID, peer, false); err != nil {
return false, err
}
}
}
r.invalidateRPCProjectionForViewer(userID)
return true, nil
}
func (r *Router) currentBlockedUserIDs(ctx context.Context, userID int64) (map[int64]bool, error) {
out := make(map[int64]bool)
if r.deps.Contacts == nil || userID == 0 {
return out, nil
}
offset := 0
for {
list, err := r.deps.Contacts.GetBlocked(ctx, userID, offset, 100)
if err != nil {
return nil, internalErr()
}
if list.Count > maxContactSetBlocked {
return nil, limitInvalidErr()
}
for _, item := range list.Blocked {
if item.User.ID != 0 {
out[item.User.ID] = true
}
}
offset += len(list.Blocked)
if len(list.Blocked) == 0 || offset >= list.Count {
break
}
}
return out, nil
}
func (r *Router) applyContactBlocklistChange(ctx context.Context, userID int64, peer domain.Peer, blocked bool) error {
if err := r.recordPeerStoryBlocked(ctx, userID, peer, blocked); err != nil {
return internalErr()
}
if err := r.fanoutStoryBlocklistChange(ctx, userID, peer.ID, blocked); err != nil {
return err
}
if r.deps.Contacts != nil {
if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil {
_ = r.recordPeerSettings(ctx, userID, peer, settings)
}
}
return nil
}
func (r *Router) onContactsGetBlocked(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -147,7 +454,7 @@ func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.Cont
if notModified {
return &tg.ContactsContactsNotModified{}, nil
}
return tgContacts(r.withContactListPresence(list)), nil
return r.tgContacts(ctx, userID, list), nil
}
func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus, error) {
@ -270,6 +577,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)
out.RetryContacts = append(out.RetryContacts, res.RetryContacts...)
for _, contact := range res.Contacts {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
@ -293,6 +601,7 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
r.log.Warn("contacts.importContacts record contacts reset failed", append(r.contextLogFields(ctx), zap.Error(err), zap.Int("contacts", len(items)))...)
return nil, internalErr()
}
r.invalidateRPCProjectionForViewer(userID)
r.pushContactsReset(ctx, userID)
return out, nil
}
@ -356,6 +665,7 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
return nil, err
}
}
r.invalidateRPCProjectionForViewer(userID)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
return updates, nil
}
@ -407,6 +717,7 @@ func (r *Router) onContactsAcceptContact(ctx context.Context, id tg.InputUserCla
return nil, err
}
r.invalidateRPCProjectionForViewer(userID)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
return updates, nil
}
@ -467,6 +778,7 @@ func (r *Router) onContactsDeleteContacts(ctx context.Context, ids []tg.InputUse
return nil, internalErr()
}
}
r.invalidateRPCProjectionForViewer(userID)
out := &tg.Updates{Updates: updates, Users: users, Date: int(r.clock.Now().Unix())}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
return out, nil
@ -496,6 +808,7 @@ func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.Contac
if err := r.recordContactsReset(ctx, userID); err != nil {
return false, internalErr()
}
r.invalidateRPCProjectionForViewer(userID)
r.pushContactsReset(ctx, userID)
return true, nil
}
@ -538,7 +851,7 @@ func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchReq
res.MyChannelResults = channelRes.MyResults
res.ChannelResults = channelRes.Results
}
return tgContactsFound(userID, r.withUserSearchPresence(res)), nil
return r.tgContactsFound(ctx, userID, r.withUserSearchPresence(res)), nil
}
func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.ContactsResolveUsernameRequest) (*tg.ContactsResolvedPeer, error) {
@ -552,7 +865,7 @@ func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.Contacts
return nil, usernameErr(err)
}
if found {
return r.tgResolvedUserPeer(userID, u), nil
return r.tgResolvedUserPeerWithStories(ctx, userID, u), nil
}
}
if r.deps.Channels != nil {
@ -561,7 +874,11 @@ func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.Contacts
return nil, usernameErr(err)
}
if found {
return tgResolvedChannelPeer(userID, ch), nil
view, err := r.deps.Channels.ResolveChannel(ctx, userID, ch.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
return r.tgResolvedChannelPeerWithStories(ctx, userID, view), nil
}
}
return nil, usernameNotOccupiedErr()
@ -586,7 +903,7 @@ func (r *Router) onContactsResolvePhone(ctx context.Context, phone string) (*tg.
if !found {
return nil, phoneNotOccupiedErr()
}
return r.tgResolvedUserPeer(userID, u), nil
return r.tgResolvedUserPeerWithStories(ctx, userID, u), nil
}
func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.ContactsResolvedPeer {
@ -602,10 +919,10 @@ func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.Cont
}
}
func tgResolvedChannelPeer(currentUserID int64, ch domain.Channel) *tg.ContactsResolvedPeer {
func tgResolvedChannelPeer(currentUserID int64, view domain.ChannelView) *tg.ContactsResolvedPeer {
return &tg.ContactsResolvedPeer{
Peer: &tg.PeerChannel{ChannelID: ch.ID},
Chats: []tg.ChatClass{tgChannelChat(currentUserID, ch, nil)},
Peer: &tg.PeerChannel{ChannelID: view.Channel.ID},
Chats: []tg.ChatClass{tgChannelChatForView(currentUserID, view)},
}
}
@ -643,7 +960,7 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p
}
}
users = append(users, r.tgUser(peerUser))
return &tg.Updates{
out := &tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdatePeerSettings{
Peer: &tg.PeerUser{UserID: peerUser.ID},
@ -654,6 +971,8 @@ 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)
return out
}
func (r *Router) recordAcceptedContactTargetUpdates(ctx context.Context, userID, targetUserID int64) error {
@ -706,7 +1025,10 @@ func (r *Router) recordContactsResetForUser(ctx context.Context, authKeyID [8]by
if r.deps.Updates == nil || userID == 0 {
return nil
}
_, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, excludeSessionID)
event, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, excludeSessionID)
if err == nil && excludeSessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
return err
}
@ -716,11 +1038,27 @@ func (r *Router) recordPeerSettings(ctx context.Context, userID int64, peer doma
return r.recordPeerSettingsForUser(ctx, authKeyID, userID, peer, settings, sessionID)
}
func (r *Router) recordPeerStoryBlocked(ctx context.Context, userID int64, peer domain.Peer, blocked bool) error {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
if r.deps.Updates == nil || userID == 0 {
return nil
}
event, _, err := r.deps.Updates.RecordPeerStoryBlocked(ctx, authKeyID, userID, peer, blocked, sessionID)
if err == nil && sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
return err
}
func (r *Router) recordPeerSettingsForUser(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
_, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, excludeSessionID)
event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, excludeSessionID)
if err == nil && excludeSessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
return err
}
@ -748,12 +1086,23 @@ func tgPeerSettings(settings domain.PeerSettings) tg.PeerSettings {
if settings.HiddenPeerSettingsBar {
return tg.PeerSettings{}
}
return tg.PeerSettings{
out := tg.PeerSettings{
AddContact: settings.AddContact,
BlockContact: settings.BlockContact,
ShareContact: settings.ShareContact,
NeedContactsException: settings.NeedContactsException,
}
if settings.BusinessBotID != 0 {
out.SetBusinessBotID(settings.BusinessBotID)
out.SetBusinessBotManageURL(settings.BusinessBotManageURL)
if settings.BusinessBotPaused {
out.SetBusinessBotPaused(true)
}
if settings.BusinessBotCanReply {
out.SetBusinessBotCanReply(true)
}
}
return out
}
func contactErr(err error) error {

View file

@ -0,0 +1,17 @@
package rpc
import (
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"testing"
)
func TestRouterTGUserUsesPersistedLastSeen(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
got := r.tgUser(domain.User{ID: 1000000002, FirstName: "Bob", LastSeenAt: 1700000000})
if status, ok := got.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 {
t.Fatalf("status = %#v, want userStatusOffline was_online=1700000000", got.Status)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,10 @@
package rpc
import "context"
import (
"context"
"regexp"
"strings"
)
type ctxKey int
@ -11,6 +15,19 @@ const (
authKeyIDKey
sessionIDKey
userIDKey
invokeWithoutUpdatesKey
)
const currentClientLayer = 227
var androidSDKVersionRE = regexp.MustCompile(`\bsdk\s+\d+\b`)
type ClientType string
const (
ClientTypeUnknown ClientType = "unknown"
ClientTypeTDesktop ClientType = "tdesktop"
ClientTypeAndroid ClientType = "android"
)
// ClientInfo 是 initConnection 携带的客户端信息。
@ -22,6 +39,7 @@ type ClientInfo struct {
SystemLangCode string
LangPack string
LangCode string
Type ClientType
}
// WithLayer 在 ctx 注入客户端 layer来自 invokeWithLayer
@ -39,6 +57,7 @@ func LayerFrom(ctx context.Context) int {
// WithClientInfo 在 ctx 注入客户端信息(来自 initConnection
func WithClientInfo(ctx context.Context, info ClientInfo) context.Context {
info = normalizeClientInfo(info)
return context.WithValue(ctx, clientInfoKey, info)
}
@ -48,6 +67,54 @@ func ClientInfoFrom(ctx context.Context) (ClientInfo, bool) {
return v, ok
}
func ClientTypeFrom(ctx context.Context) ClientType {
if info, ok := ClientInfoFrom(ctx); ok {
return info.ClientType()
}
return ClientTypeUnknown
}
func normalizeClientInfo(info ClientInfo) ClientInfo {
if !knownClientType(info.Type) {
info.Type = detectClientType(info)
}
return info
}
func (info ClientInfo) ClientType() ClientType {
if knownClientType(info.Type) {
return info.Type
}
return detectClientType(info)
}
func knownClientType(t ClientType) bool {
switch t {
case ClientTypeTDesktop, ClientTypeAndroid:
return true
default:
return false
}
}
func detectClientType(info ClientInfo) ClientType {
if strings.EqualFold(info.LangPack, string(ClientTypeAndroid)) {
return ClientTypeAndroid
}
if strings.EqualFold(info.LangPack, string(ClientTypeTDesktop)) {
return ClientTypeTDesktop
}
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
switch {
case strings.Contains(client, "android"), androidSDKVersionRE.MatchString(client):
return ClientTypeAndroid
case strings.Contains(client, "tdesktop"), strings.Contains(client, "desktop"):
return ClientTypeTDesktop
default:
return ClientTypeUnknown
}
}
// WithRawAuthKeyID 在 ctx 注入连接实际使用的 auth_key_id。
func WithRawAuthKeyID(ctx context.Context, id [8]byte) context.Context {
return context.WithValue(ctx, rawAuthKeyIDKey, id)
@ -94,3 +161,14 @@ func UserIDFrom(ctx context.Context) (int64, bool) {
}
return v, true
}
// withInvokeWithoutUpdates 标记当前请求被 invokeWithoutUpdates 包装:
// 客户端声明该 session 不接收主动 updatesmedia/temp 连接的请求一律带此包装)。
func withInvokeWithoutUpdates(ctx context.Context) context.Context {
return context.WithValue(ctx, invokeWithoutUpdatesKey, true)
}
func invokeWithoutUpdatesFrom(ctx context.Context) bool {
v, _ := ctx.Value(invokeWithoutUpdatesKey).(bool)
return v
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
package rpc
import (
"context"
"telesrv/internal/domain"
)
// authzFromCtx 从连接上下文组装一条待绑定的设备授权UserID 由业务层填充)。
func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization {
id, _ := AuthKeyIDFrom(ctx)
a := domain.Authorization{AuthKeyID: id, Layer: LayerFrom(ctx)}
if ci, ok := ClientInfoFrom(ctx); ok {
a.DeviceModel = ci.DeviceModel
a.Platform = string(ci.ClientType())
a.SystemVersion = ci.SystemVersion
a.AppVersion = ci.AppVersion
a.APIID = ci.APIID
}
return a
}
// currentUserID 返回当前连接已登录的 user_id。
//
// 优先使用 active session 缓存;若新连接尚未绑定但 auth_key 已授权,则只在这里
// 查询一次授权表并回填 session避免各业务 service 每个 RPC 重复 authKey→userID。
func (r *Router) currentUserID(ctx context.Context) (int64, bool, error) {
if userID, ok := UserIDFrom(ctx); ok {
return userID, true, nil
}
if r.deps.Sessions != nil {
if sessionID, ok := SessionIDFrom(ctx); ok {
if scoped, ok := r.scopedSessions(); ok {
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved {
if userID == 0 {
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
if cachedUserID, ok := r.positiveCachedAuthUser(authKeyID); ok {
scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, cachedUserID)
r.announceSessionOnline(ctx, cachedUserID)
return cachedUserID, true, nil
}
}
}
return userID, userID != 0, nil
}
}
} else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved {
if userID == 0 {
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
if cachedUserID, ok := r.positiveCachedAuthUser(authKeyID); ok {
r.deps.Sessions.BindUser(sessionID, cachedUserID)
r.announceSessionOnline(ctx, cachedUserID)
return cachedUserID, true, nil
}
}
}
return userID, userID != 0, nil
}
}
}
if r.deps.Auth == nil {
return 0, false, nil
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok {
return 0, false, nil
}
userID, found, err := r.lookupAuthUser(ctx, authKeyID)
if err != nil || !found {
if err == nil && !found {
r.bindSessionUser(ctx, 0)
}
return 0, found, err
}
r.bindSessionUser(ctx, userID)
return userID, true, nil
}

View file

@ -0,0 +1,460 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func domainBusinessWorkHours(req *tg.AccountUpdateBusinessWorkHoursRequest) (*domain.BusinessWorkHours, error) {
if req == nil {
return nil, nil
}
hours, ok := req.GetBusinessWorkHours()
if !ok {
return nil, nil
}
out := &domain.BusinessWorkHours{
TimezoneID: hours.TimezoneID,
WeeklyOpen: make([]domain.BusinessWeeklyOpen, 0, len(hours.WeeklyOpen)),
}
for _, item := range hours.WeeklyOpen {
out.WeeklyOpen = append(out.WeeklyOpen, domain.BusinessWeeklyOpen{
StartMinute: item.StartMinute,
EndMinute: item.EndMinute,
})
}
return out, nil
}
func tgBusinessWorkHours(in *domain.BusinessWorkHours) (tg.BusinessWorkHours, bool) {
if in == nil {
return tg.BusinessWorkHours{}, false
}
out := tg.BusinessWorkHours{
TimezoneID: in.TimezoneID,
WeeklyOpen: make([]tg.BusinessWeeklyOpen, 0, len(in.WeeklyOpen)),
}
for _, item := range in.WeeklyOpen {
out.WeeklyOpen = append(out.WeeklyOpen, tg.BusinessWeeklyOpen{
StartMinute: item.StartMinute,
EndMinute: item.EndMinute,
})
}
if in.OpenNow {
out.SetOpenNow(true)
}
return out, true
}
func domainBusinessLocation(req *tg.AccountUpdateBusinessLocationRequest) (*domain.BusinessLocation, error) {
if req == nil {
return nil, nil
}
address, addressSet := req.GetAddress()
geo, geoSet := req.GetGeoPoint()
if !addressSet && !geoSet {
return nil, nil
}
out := &domain.BusinessLocation{Address: address}
if geoSet {
switch point := geo.(type) {
case *tg.InputGeoPoint:
out.Geo = &domain.GeoPoint{Lat: point.Lat, Long: point.Long}
case *tg.InputGeoPointEmpty, nil:
default:
return nil, inputConstructorInvalidErr()
}
}
return out, nil
}
func tgBusinessLocation(in *domain.BusinessLocation) (tg.BusinessLocation, bool) {
if in == nil {
return tg.BusinessLocation{}, false
}
out := tg.BusinessLocation{Address: in.Address}
if in.Geo != nil {
out.SetGeoPoint(&tg.GeoPoint{Lat: in.Geo.Lat, Long: in.Geo.Long, AccessHash: 1})
}
return out, true
}
func domainBusinessIntro(req *tg.AccountUpdateBusinessIntroRequest) (*domain.BusinessIntro, error) {
if req == nil {
return nil, nil
}
intro, ok := req.GetIntro()
if !ok {
return nil, nil
}
out := &domain.BusinessIntro{
Title: intro.Title,
Description: intro.Description,
}
if sticker, ok := intro.GetSticker(); ok {
doc, ok := sticker.(*tg.InputDocument)
if !ok || doc.ID == 0 {
return nil, documentInvalidErr()
}
out.StickerDocumentID = doc.ID
}
return out, nil
}
func (r *Router) tgBusinessIntro(ctx context.Context, in *domain.BusinessIntro) (tg.BusinessIntro, bool) {
if in == nil {
return tg.BusinessIntro{}, false
}
out := tg.BusinessIntro{Title: in.Title, Description: in.Description}
if in.StickerDocumentID != 0 && r.deps.Files != nil {
if doc, found, err := r.deps.Files.GetDocument(ctx, in.StickerDocumentID); err == nil && found {
out.SetSticker(tgDocument(doc))
}
}
return out, true
}
func (r *Router) domainBusinessGreeting(ctx context.Context, currentUserID int64, req *tg.AccountUpdateBusinessGreetingMessageRequest) (*domain.BusinessGreetingMessage, error) {
if req == nil {
return nil, nil
}
message, ok := req.GetMessage()
if !ok {
return nil, nil
}
recipients, err := r.domainBusinessRecipients(ctx, currentUserID, message.Recipients)
if err != nil {
return nil, err
}
return &domain.BusinessGreetingMessage{
ShortcutID: message.ShortcutID,
Recipients: recipients,
NoActivityDays: message.NoActivityDays,
}, nil
}
func tgBusinessGreeting(in *domain.BusinessGreetingMessage) (tg.BusinessGreetingMessage, bool) {
if in == nil {
return tg.BusinessGreetingMessage{}, false
}
return tg.BusinessGreetingMessage{
ShortcutID: in.ShortcutID,
Recipients: tgBusinessRecipients(in.Recipients),
NoActivityDays: in.NoActivityDays,
}, true
}
func (r *Router) domainBusinessAway(ctx context.Context, currentUserID int64, req *tg.AccountUpdateBusinessAwayMessageRequest) (*domain.BusinessAwayMessage, error) {
if req == nil {
return nil, nil
}
message, ok := req.GetMessage()
if !ok {
return nil, nil
}
schedule, err := domainBusinessAwaySchedule(message.Schedule)
if err != nil {
return nil, err
}
recipients, err := r.domainBusinessRecipients(ctx, currentUserID, message.Recipients)
if err != nil {
return nil, err
}
return &domain.BusinessAwayMessage{
ShortcutID: message.ShortcutID,
Schedule: schedule,
Recipients: recipients,
OfflineOnly: message.OfflineOnly,
}, nil
}
func tgBusinessAway(in *domain.BusinessAwayMessage) (tg.BusinessAwayMessage, bool) {
if in == nil {
return tg.BusinessAwayMessage{}, false
}
out := tg.BusinessAwayMessage{
ShortcutID: in.ShortcutID,
Schedule: tgBusinessAwaySchedule(in.Schedule),
Recipients: tgBusinessRecipients(in.Recipients),
}
if in.OfflineOnly {
out.SetOfflineOnly(true)
}
return out, true
}
func (r *Router) domainBusinessRecipients(ctx context.Context, currentUserID int64, in tg.InputBusinessRecipients) (domain.BusinessRecipients, error) {
out := domain.BusinessRecipients{
ExistingChats: in.ExistingChats,
NewChats: in.NewChats,
Contacts: in.Contacts,
NonContacts: in.NonContacts,
ExcludeSelected: in.ExcludeSelected,
}
if len(in.Users) == 0 {
return out, nil
}
seen := make(map[int64]struct{}, len(in.Users))
for _, input := range in.Users {
user, found, err := r.userFromInput(ctx, currentUserID, input)
if err != nil {
return domain.BusinessRecipients{}, internalErr()
}
if !found || user.ID == 0 {
return domain.BusinessRecipients{}, userIDInvalidErr()
}
if _, ok := seen[user.ID]; ok {
continue
}
seen[user.ID] = struct{}{}
out.Users = append(out.Users, user.ID)
}
return out, nil
}
func tgBusinessRecipients(in domain.BusinessRecipients) tg.BusinessRecipients {
out := tg.BusinessRecipients{
ExistingChats: in.ExistingChats,
NewChats: in.NewChats,
Contacts: in.Contacts,
NonContacts: in.NonContacts,
ExcludeSelected: in.ExcludeSelected,
}
if len(in.Users) > 0 {
out.SetUsers(append([]int64(nil), in.Users...))
}
return out
}
func (r *Router) domainBusinessBotRecipients(ctx context.Context, currentUserID int64, in tg.InputBusinessBotRecipients) (domain.BusinessBotRecipients, error) {
users, err := r.businessBotRecipientUserIDs(ctx, currentUserID, in.Users)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
excludeUsers, err := r.businessBotRecipientUserIDs(ctx, currentUserID, in.ExcludeUsers)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
return domain.BusinessBotRecipients{
ExistingChats: in.ExistingChats,
NewChats: in.NewChats,
Contacts: in.Contacts,
NonContacts: in.NonContacts,
ExcludeSelected: in.ExcludeSelected,
Users: users,
ExcludeUsers: excludeUsers,
}, nil
}
func (r *Router) businessBotRecipientUserIDs(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) {
if len(inputs) == 0 {
return nil, nil
}
out := make([]int64, 0, len(inputs))
seen := make(map[int64]struct{}, len(inputs))
for _, input := range inputs {
user, found, err := r.userFromInput(ctx, currentUserID, input)
if err != nil {
return nil, internalErr()
}
if !found || user.ID == 0 {
return nil, userIDInvalidErr()
}
if _, ok := seen[user.ID]; ok {
continue
}
seen[user.ID] = struct{}{}
out = append(out, user.ID)
}
return out, nil
}
func tgBusinessBotRecipients(in domain.BusinessBotRecipients) tg.BusinessBotRecipients {
out := tg.BusinessBotRecipients{
ExistingChats: in.ExistingChats,
NewChats: in.NewChats,
Contacts: in.Contacts,
NonContacts: in.NonContacts,
ExcludeSelected: in.ExcludeSelected,
}
if len(in.Users) > 0 {
out.SetUsers(append([]int64(nil), in.Users...))
}
if len(in.ExcludeUsers) > 0 {
out.SetExcludeUsers(append([]int64(nil), in.ExcludeUsers...))
}
return out
}
func domainBusinessBotRights(in tg.BusinessBotRights) domain.BusinessBotRights {
return domain.BusinessBotRights{
Reply: in.Reply,
ReadMessages: in.ReadMessages,
DeleteSentMessages: in.DeleteSentMessages,
DeleteReceivedMessages: in.DeleteReceivedMessages,
EditName: in.EditName,
EditBio: in.EditBio,
EditProfilePhoto: in.EditProfilePhoto,
EditUsername: in.EditUsername,
ViewGifts: in.ViewGifts,
SellGifts: in.SellGifts,
ChangeGiftSettings: in.ChangeGiftSettings,
TransferAndUpgradeGifts: in.TransferAndUpgradeGifts,
TransferStars: in.TransferStars,
ManageStories: in.ManageStories,
}
}
func domainBusinessBotRightsForUpdate(req *tg.AccountUpdateConnectedBotRequest) domain.BusinessBotRights {
if rights, ok := req.GetRights(); ok {
return domainBusinessBotRights(rights)
}
return domain.BusinessBotRights{Reply: true}
}
func tgBusinessBotRights(in domain.BusinessBotRights) tg.BusinessBotRights {
return tg.BusinessBotRights{
Reply: in.Reply,
ReadMessages: in.ReadMessages,
DeleteSentMessages: in.DeleteSentMessages,
DeleteReceivedMessages: in.DeleteReceivedMessages,
EditName: in.EditName,
EditBio: in.EditBio,
EditProfilePhoto: in.EditProfilePhoto,
EditUsername: in.EditUsername,
ViewGifts: in.ViewGifts,
SellGifts: in.SellGifts,
ChangeGiftSettings: in.ChangeGiftSettings,
TransferAndUpgradeGifts: in.TransferAndUpgradeGifts,
TransferStars: in.TransferStars,
ManageStories: in.ManageStories,
}
}
func tgConnectedBot(in domain.ConnectedBusinessBot) tg.ConnectedBot {
return tg.ConnectedBot{
BotID: in.BotUserID,
Recipients: tgBusinessBotRecipients(in.Recipients),
Rights: tgBusinessBotRights(in.Rights),
}
}
func domainBusinessAwaySchedule(in tg.BusinessAwayMessageScheduleClass) (domain.BusinessAwaySchedule, error) {
switch schedule := in.(type) {
case *tg.BusinessAwayMessageScheduleAlways:
return domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleAlways}, nil
case *tg.BusinessAwayMessageScheduleOutsideWorkHours:
return domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleOutsideWorkHours}, nil
case *tg.BusinessAwayMessageScheduleCustom:
return domain.BusinessAwaySchedule{
Kind: domain.BusinessAwayScheduleCustom,
StartDate: schedule.StartDate,
EndDate: schedule.EndDate,
}, nil
default:
return domain.BusinessAwaySchedule{}, inputConstructorInvalidErr()
}
}
func tgBusinessAwaySchedule(in domain.BusinessAwaySchedule) tg.BusinessAwayMessageScheduleClass {
switch in.Kind {
case domain.BusinessAwayScheduleOutsideWorkHours:
return &tg.BusinessAwayMessageScheduleOutsideWorkHours{}
case domain.BusinessAwayScheduleCustom:
return &tg.BusinessAwayMessageScheduleCustom{StartDate: in.StartDate, EndDate: in.EndDate}
default:
return &tg.BusinessAwayMessageScheduleAlways{}
}
}
func domainBusinessChatLinkInput(in tg.InputBusinessChatLink) (domain.BusinessChatLinkInput, error) {
entities, _ := in.GetEntities()
title, _ := in.GetTitle()
return domain.BusinessChatLinkInput{
Message: in.Message,
Entities: domainMessageEntities(entities),
Title: title,
}, nil
}
func tgBusinessChatLink(in domain.BusinessChatLink) tg.BusinessChatLink {
return tg.BusinessChatLink{
Link: in.Link,
Message: in.Message,
Entities: tgMessageEntities(in.Entities),
Title: in.Title,
Views: in.Views,
}
}
func tgBusinessChatLinks(in []domain.BusinessChatLink) []tg.BusinessChatLink {
out := make([]tg.BusinessChatLink, 0, len(in))
for _, item := range in {
out = append(out, tgBusinessChatLink(item))
}
return out
}
func tgQuickReply(in domain.QuickReply) tg.QuickReply {
return tg.QuickReply{
ShortcutID: in.ID,
Shortcut: in.Shortcut,
TopMessage: in.TopMessage,
Count: in.Count,
}
}
func tgQuickReplies(in []domain.QuickReply) []tg.QuickReply {
out := make([]tg.QuickReply, 0, len(in))
for _, item := range in {
out = append(out, tgQuickReply(item))
}
return out
}
func tgQuickReplyMessage(in domain.QuickReplyMessage) tg.MessageClass {
if in.ID <= 0 {
return nil
}
out := &tg.Message{
Out: true,
ID: in.ID,
FromID: &tg.PeerUser{UserID: in.OwnerUserID},
PeerID: &tg.PeerUser{UserID: in.OwnerUserID},
Date: in.Date,
Message: in.Message,
Entities: tgMessageEntities(in.Entities),
}
out.SetQuickReplyShortcutID(in.ShortcutID)
return out
}
func tgQuickReplyMessages(in []domain.QuickReplyMessage) []tg.MessageClass {
out := make([]tg.MessageClass, 0, len(in))
for _, item := range in {
if msg := tgQuickReplyMessage(item); msg != nil {
out = append(out, msg)
}
}
return out
}
func tgMessagesQuickReplies(in domain.QuickReplyList) *tg.MessagesQuickReplies {
return &tg.MessagesQuickReplies{
QuickReplies: tgQuickReplies(in.QuickReplies),
Messages: tgQuickReplyMessages(in.Messages),
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
func tgMessagesQuickReplyMessages(in domain.QuickReplyMessages) *tg.MessagesMessages {
return &tg.MessagesMessages{
Messages: tgQuickReplyMessages(in.Messages),
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}

View file

@ -0,0 +1,850 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
const defaultChatBannedRightsUntilDate = 2147483647
func tgChannelChatsWithPrimarySelf(viewerUserID int64, primary domain.Channel, extras []domain.Channel, primarySelf domain.ChannelMember) []tg.ChatClass {
channels := make([]domain.Channel, 0, len(extras)+1)
channels = append(channels, primary)
channels = append(channels, extras...)
out := make([]tg.ChatClass, 0, len(channels))
seen := make(map[int64]struct{}, len(channels))
for _, ch := range channels {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
var self *domain.ChannelMember
if ch.ID == primary.ID && primarySelf.ChannelID == ch.ID && primarySelf.UserID == viewerUserID {
self = &primarySelf
}
out = append(out, tgChannelChat(viewerUserID, ch, self))
}
return out
}
func tgChannelHistoryMessages(viewerUserID int64, list domain.ChannelHistory) tg.MessagesMessagesClass {
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, msg := range list.Messages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
messages = append(messages, item)
}
}
topics := make([]tg.ForumTopicClass, 0, len(list.Topics))
for _, topic := range list.Topics {
item := tgForumTopicFromDomain(viewerUserID, topic)
item.SetShort(true)
topics = append(topics, item)
}
// viewer 自己发过言时会出现在 history.Users 里;必须经 viewer 分支打 self 标志,
// 否则 DrKLO putUsers 会用 self=false 覆盖账号缓存Saved Messages 退化为普通自聊。
users := tgUsersForViewer(viewerUserID, list.Users)
chats := tgChannelChatsWithPrimarySelf(viewerUserID, list.Channel, list.Channels, list.Self)
if list.Channel.ID != 0 {
return &tg.MessagesChannelMessages{
Pts: list.Channel.Pts,
Count: list.Count,
Messages: messages,
Topics: topics,
Chats: chats,
Users: users,
}
}
if list.Count > len(messages) {
return &tg.MessagesMessagesSlice{
Count: list.Count,
Messages: messages,
Topics: topics,
Chats: chats,
Users: users,
}
}
return &tg.MessagesMessages{Messages: messages, Topics: topics, Chats: chats, Users: users}
}
func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageClass {
if m.ID == 0 || m.ChannelID == 0 {
return nil
}
peer := &tg.PeerChannel{ChannelID: m.ChannelID}
outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel
from := tg.PeerClass(nil)
if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 {
from = tgPeer(*m.SendAs)
}
if !m.Post && from == nil && m.From.ID != 0 {
from = tgPeer(m.From)
}
if from == nil && m.SenderUserID != 0 && !m.Post {
from = &tg.PeerUser{UserID: m.SenderUserID}
}
if m.Action != nil && m.Action.Type == domain.ChannelActionCreate {
// messageActionChannelCreate 是频道级「创建」事件,不带发送者(对齐官方)。否则在 monoforum
// 管理视图里,这条 from=管理员 的创建消息会被客户端按发送者归成一个虚假的「管理员」子会话,
// 点开 monoforum 就进了那个人的个人资料而非私信管理视图。out 仍保留(管理员触发,= viewer)。
from = nil
}
if m.Action != nil {
msg := &tg.MessageService{
Out: outgoing,
Silent: m.Silent,
Post: m.Post,
ID: m.ID,
FromID: from,
PeerID: peer,
Date: m.Date,
Action: tgChannelMessageAction(*m.Action),
}
if msg.Action == nil {
msg.Action = &tg.MessageActionEmpty{}
}
if reply := tgMessageReplyHeader(domain.Message{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID},
ReplyTo: m.ReplyTo,
}); reply != nil {
msg.SetReplyTo(reply)
}
return msg
}
msg := &tg.Message{
Out: outgoing,
Silent: m.Silent,
Post: m.Post,
Noforwards: m.NoForwards,
Mentioned: m.Mentioned,
MediaUnread: m.MediaUnread,
ID: m.ID,
FromID: from,
PeerID: peer,
Date: m.Date,
Message: m.Body,
Entities: tgMessageEntities(m.Entities),
}
if m.SavedPeer.ID != 0 {
// 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
}
if m.Pinned {
msg.SetPinned(true)
}
if m.EditDate != 0 {
msg.SetEditDate(m.EditDate)
}
if reply := tgMessageReplyHeader(domain.Message{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID},
ReplyTo: m.ReplyTo,
}); reply != nil {
msg.SetReplyTo(reply)
}
if fwd := tgMessageFwdHeader(m.Forward); fwd != nil {
msg.SetFwdFrom(*fwd)
}
if m.ViaBotID != 0 {
msg.SetViaBotID(m.ViaBotID)
}
if m.GroupedID != 0 {
msg.SetGroupedID(m.GroupedID)
}
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
if replies := tgChannelMessageReplies(m.Replies); replies != nil {
msg.SetReplies(*replies)
}
if reactions := tgMessageReactions(viewerUserID, m.Reactions); reactions != nil {
msg.SetReactions(*reactions)
}
if !m.Media.IsZero() {
msg.SetMedia(tgMessageMedia(m.Media))
if m.Media.InvertMedia {
msg.SetInvertMedia(true)
}
}
if m.TTLPeriod > 0 {
msg.SetTTLPeriod(m.TTLPeriod)
}
if m.FromBoostsApplied > 0 {
msg.SetFromBoostsApplied(m.FromBoostsApplied)
}
if m.Post {
// 官方频道 post 自带 views 计数器(初始 1signatures 开启时附作者签名。
views := m.ViewsCount
if views < 1 {
views = 1
}
msg.SetViews(views)
if m.PostAuthor != "" {
msg.SetPostAuthor(m.PostAuthor)
}
}
return msg
}
func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageActionClass {
switch action.Type {
case domain.ChannelActionCreate:
return &tg.MessageActionChannelCreate{Title: action.Title}
case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined:
return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)}
case domain.ChannelActionChatJoinedByLink:
return &tg.MessageActionChatJoinedByLink{InviterID: action.InviterUserID}
case domain.ChannelActionChatDelete:
var userID int64
if len(action.UserIDs) > 0 {
userID = action.UserIDs[0]
}
return &tg.MessageActionChatDeleteUser{UserID: userID}
case domain.ChannelActionEditTitle:
return &tg.MessageActionChatEditTitle{Title: action.Title}
case domain.ChannelActionTopicCreate:
return &tg.MessageActionTopicCreate{
TitleMissing: action.TitleMissing,
Title: action.Title,
IconColor: action.IconColor,
IconEmojiID: action.IconEmojiID,
}
case domain.ChannelActionTopicEdit:
out := &tg.MessageActionTopicEdit{}
if action.Title != "" {
out.SetTitle(action.Title)
}
if action.IconEmojiIDSet {
out.SetIconEmojiID(action.IconEmojiID)
}
if action.Closed != nil {
out.SetClosed(*action.Closed)
}
if action.Hidden != nil {
out.SetHidden(*action.Hidden)
}
return out
case domain.ChannelActionTodoCompletions:
return &tg.MessageActionTodoCompletions{
Completed: append([]int(nil), action.Completed...),
Incompleted: append([]int(nil), action.Incompleted...),
}
case domain.ChannelActionTodoAppendTasks:
return &tg.MessageActionTodoAppendTasks{
List: tgTodoItems(action.TodoItems),
}
case domain.ChannelActionGroupCall:
// started无 duration与 ended带 duration共用 messageActionGroupCall。
out := &tg.MessageActionGroupCall{
Call: &tg.InputGroupCall{ID: action.CallID, AccessHash: action.CallAccessHash},
}
if action.CallDuration > 0 {
out.SetDuration(action.CallDuration)
}
return out
case domain.ChannelActionInviteToGroupCall:
return &tg.MessageActionInviteToGroupCall{
Call: &tg.InputGroupCall{ID: action.CallID, AccessHash: action.CallAccessHash},
Users: append([]int64(nil), action.UserIDs...),
}
case domain.ChannelActionBoostApply:
return &tg.MessageActionBoostApply{Boosts: action.Boosts}
case domain.ChannelActionPaidMessagesPrice:
return &tg.MessageActionPaidMessagesPrice{
BroadcastMessagesAllowed: action.BroadcastMessagesAllowed,
Stars: action.Stars,
}
case domain.ChannelActionStarGift:
return tgMessageActionStarGift(action.StarGift)
case domain.ChannelActionSetChatWallpaper:
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
}
return nil
default:
return nil
}
}
func tgChannelMessageReplies(in *domain.ChannelMessageReplies) *tg.MessageReplies {
if in == nil {
return nil
}
out := &tg.MessageReplies{
Comments: in.Comments,
Replies: in.Replies,
RepliesPts: in.RepliesPts,
}
if len(in.RecentRepliers) > 0 {
repliers := make([]tg.PeerClass, 0, len(in.RecentRepliers))
for _, peer := range in.RecentRepliers {
if converted := tgPeer(peer); converted != nil {
repliers = append(repliers, converted)
}
}
if len(repliers) > 0 {
out.SetRecentRepliers(repliers)
}
}
if in.ChannelID != 0 {
out.SetChannelID(in.ChannelID)
}
if in.MaxID > 0 {
out.SetMaxID(in.MaxID)
}
if in.ReadMaxID > 0 {
out.SetReadMaxID(in.ReadMaxID)
}
return out
}
func tgChannels(viewerUserID int64, channels []domain.Channel) []tg.ChatClass {
out := make([]tg.ChatClass, 0, len(channels))
seen := make(map[int64]struct{}, len(channels))
for _, ch := range channels {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
out = append(out, tgChannelChatMin(viewerUserID, ch))
}
return out
}
func tgChannelChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) tg.ChatClass {
if ch.Deleted {
return tgChannelForbidden(ch)
}
return tgChannel(viewerUserID, ch, self)
}
// tgChannelChatMin 是消息类 update/伴随 chats 的 min channel 形态:
// 客户端对 min 对象不应用 admin/banned rights 与 left/creator避免不带
// 接收者 member 投影的推送把本地权限状态清掉。
func tgChannelChatMin(viewerUserID int64, ch domain.Channel) tg.ChatClass {
if ch.Deleted {
return tgChannelForbidden(ch)
}
out := tgChannel(viewerUserID, ch, nil)
out.Min = true
out.Creator = false
return out
}
// tgChannelChatForView 把按 viewer 投影后的 ChannelView 转成查询响应 chat
// 被踢/被禁止查看的 viewer 收到 channelForbidden借此感知自己已离开会话。
func tgChannelChatForView(viewerUserID int64, view domain.ChannelView) tg.ChatClass {
if view.Forbidden {
return tgChannelForbidden(view.Channel)
}
self := view.Self
return tgChannelChat(viewerUserID, view.Channel, &self)
}
func tgChannelForbidden(ch domain.Channel) *tg.ChannelForbidden {
return &tg.ChannelForbidden{
Broadcast: ch.Broadcast,
Megagroup: ch.Megagroup,
ID: ch.ID,
AccessHash: ch.AccessHash,
Title: ch.Title,
}
}
func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Channel {
out := &tg.Channel{
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
Verified: ch.Verified,
Broadcast: ch.Broadcast,
Megagroup: ch.Megagroup,
Forum: ch.Forum,
ForumTabs: ch.ForumTabs,
Noforwards: ch.NoForwards,
Signatures: ch.Signatures,
ID: ch.ID,
Title: ch.Title,
Photo: tgChatPhoto(ch),
Date: ch.Date,
}
out.SetAccessHash(ch.AccessHash)
out.SetJoinToSend(ch.JoinToSend)
out.SetJoinRequest(ch.JoinRequest)
out.SetAutotranslation(ch.Autotranslation)
// broadcast_messages_allowed 只属于广播母频道。monoforum 内部用同名字段镜像 DM 启用状态(见下),
// 但它是 megagroup,绝不能把该 flag 投影出去(官方 monoforum 对象也不带它)。
if ch.Broadcast {
out.SetBroadcastMessagesAllowed(ch.BroadcastMessagesAllowed)
}
if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed || ch.Monoforum {
out.SetSendPaidMessagesStars(ch.SendPaidMessagesStars)
}
if ch.HasLink || ch.LinkedChatID != 0 {
out.SetHasLink(true)
}
if ch.Monoforum {
out.SetMonoforum(true)
}
// linked_monoforum_id 仅在 DM 启用时下发(母频道与 monoforum 都用 BroadcastMessagesAllowed 表示
// DM 启用状态,monoforum 由 SetPaidMessagesPrice 镜像母频道)。关闭 Direct Messages 时**双方都**隐藏
// 该 id:母频道隐藏触发 monoforum 的 MonoforumDisabled;monoforum 也必须隐藏,否则打开 monoforum
// 重新拉取时 mono 仍带 link → setMonoforumLink(parent) 会把 MonoforumDisabled 清掉、停用页脚消失。
// 内部关联行仍保留以便重新开启复用。
if ch.LinkedMonoforumID != 0 && ch.BroadcastMessagesAllowed {
out.SetLinkedMonoforumID(ch.LinkedMonoforumID)
}
if ch.Username != "" {
out.SetUsername(ch.Username)
out.SetUsernames(tgUsernames(ch.Username))
}
if color := tgPeerColor(ch.Color); color != nil {
out.SetColor(color)
}
if profileColor := tgPeerColor(ch.ProfileColor); profileColor != nil {
out.SetProfileColor(profileColor)
}
if status := tgChannelEmojiStatus(ch.EmojiStatus); status != nil {
out.SetEmojiStatus(status)
}
if ch.SlowmodeSeconds > 0 {
out.SetSlowmodeEnabled(true)
}
if ch.ParticipantsCount > 0 {
out.SetParticipantsCount(ch.ParticipantsCount)
}
out.SetDefaultBannedRights(tgDefaultChatBannedRights(ch.DefaultBannedRights))
// 群通话 banner 数据源call_active/call_not_empty flagAndroid 对 flag 依赖
// 更重call_not_empty 翻转时还会经 pushChannelStateToMembers 补推)。
out.CallActive = ch.ActiveCallID != 0
out.CallNotEmpty = ch.ActiveCallNotEmpty
if ch.Monoforum {
// Monoforum(频道私信容器)绝不能在自身对象上带 Creator/admin_rights。TDesktop 的
// NeedAboutGroup 对 megagroup 看 amCreator() 决定是否画 "You created a group" 群聊空状态,
// 且订阅数/Leave/Channel 等 chrome 也 key off amCreator/asMegagroup —— 一旦 mono 自身
// Creator=true 就会被渲染成普通群。Direct-Messages 容器身份(本地 MonoforumAdmin 标志)由
// 客户端从母频道 canAccessMonoforum(amCreator 或 manage_direct_messages)派生,与 mono 自身
// 的 Creator/admin 无关。服务端的私信发送鉴权走母频道 membership,不依赖此 flag。
out.Creator = false
} else if self != nil {
if self.Status == domain.ChannelMemberLeft {
out.Left = true
}
switch self.Role {
case domain.ChannelRoleCreator:
out.Creator = true
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
case domain.ChannelRoleAdmin:
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
}
if !zeroChannelBannedRights(self.BannedRights) {
out.SetBannedRights(tgChatBannedRights(self.BannedRights))
}
}
return out
}
func tgChannelFull(view domain.ChannelView) *tg.ChannelFull {
ch := view.Channel
full := &tg.ChannelFull{
// 广播频道的订阅者列表仅管理员可见(官方语义):非管理员订阅者拿到
// can_view_participants=falseProfile 的 Subscribers/Administrators/Channel Settings
// 三行随之隐藏。megagroup 成员可见(隐藏成员时仅管理员)。
CanViewParticipants: channelMemberIsAdmin(view.Self) || !ch.MembersListAdminOnly(),
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
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)
}
if ch.ActiveCallID != 0 {
// 入会面板/banner 点击的入口:客户端拿它调 phone.getGroupCall。
full.SetCall(&tg.InputGroupCall{ID: ch.ActiveCallID, AccessHash: ch.ActiveCallAccessHash})
}
if view.Self.AvailableMinID > 0 {
// 客户端用它裁剪本地缓存的入群前/已清空历史clearUpTill
full.SetAvailableMinID(view.Self.AvailableMinID)
}
if view.ExportedInvite != nil && !view.ExportedInvite.Revoked {
full.SetExportedInvite(tgExportedChannelInvite(*view.ExportedInvite))
}
if ch.AdminsCount > 0 {
full.SetAdminsCount(ch.AdminsCount)
}
if ch.KickedCount > 0 {
full.SetKickedCount(ch.KickedCount)
}
if ch.BannedCount > 0 {
full.SetBannedCount(ch.BannedCount)
}
if view.Dialog.FolderID != domain.DialogMainFolderID {
full.SetFolderID(view.Dialog.FolderID)
}
if ch.TTLPeriod > 0 {
full.SetTTLPeriod(ch.TTLPeriod)
}
if view.Dialog.HasScheduled {
full.SetHasScheduled(true)
}
if ch.PreHistoryHidden {
full.SetHiddenPrehistory(true)
}
if ch.ParticipantsHidden {
full.SetParticipantsHidden(true)
}
if ch.AntiSpam {
full.SetAntispam(true)
}
if ch.RestrictedSponsored {
full.SetRestrictedSponsored(true)
}
if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed {
full.SetPaidMessagesAvailable(true)
full.SetSendPaidMessagesStars(ch.SendPaidMessagesStars)
}
if view.Dialog.ViewForumAsMessages {
full.SetViewForumAsMessages(true)
}
if ch.LinkedChatID != 0 {
full.SetLinkedChatID(ch.LinkedChatID)
}
if defaultSendAs := validDefaultSendAsPeer(view); defaultSendAs != nil {
full.SetDefaultSendAs(tgPeer(*defaultSendAs))
}
if ch.SlowmodeSeconds > 0 {
full.SetSlowmodeSeconds(ch.SlowmodeSeconds)
if view.Self.SlowmodeLastSendDate > 0 {
full.SetSlowmodeNextSendDate(view.Self.SlowmodeLastSendDate + ch.SlowmodeSeconds)
}
}
if view.SelfBoostsApplied > 0 {
full.SetBoostsApplied(view.SelfBoostsApplied)
}
if ch.BoostsUnrestrict > 0 {
full.SetBoostsUnrestrict(ch.BoostsUnrestrict)
}
if ch.PinnedMessageID > 0 {
full.SetPinnedMsgID(ch.PinnedMessageID)
}
if wallpaper := tgWallpaper(ch.Wallpaper); wallpaper != nil {
full.SetWallpaper(wallpaper)
}
if reactions := tgChannelReactionPolicy(ch.ReactionPolicy); reactions != nil {
full.SetAvailableReactions(reactions)
}
if ch.ReactionPolicy.Limit > 0 {
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
}
// 付费 reactionStars是广播频道默认能力——官方语义下 channelFull.paid_reactions_available
// 对广播频道恒真客户端据此显示星按钮megagroup 不支持。与 store 侧
// AddChannelMessagePaidReaction 仅广播频道的门槛一致。显式 PaidEnabled 也保留。
if (ch.Broadcast && !ch.Megagroup) || ch.ReactionPolicy.PaidEnabled {
full.SetPaidReactionsAvailable(true)
}
if ch.Broadcast && !ch.Megagroup {
full.SetStargiftsAvailable(true)
}
return full
}
func channelMemberIsAdmin(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
}
func tgChannelReactionPolicy(policy domain.ChannelReactionPolicy) tg.ChatReactionsClass {
switch policy.Type {
case domain.ChannelReactionPolicyNone:
return &tg.ChatReactionsNone{}
case domain.ChannelReactionPolicyAll:
out := &tg.ChatReactionsAll{}
if policy.AllowCustom {
out.SetAllowCustom(true)
}
return out
case domain.ChannelReactionPolicySome:
reactions := make([]tg.ReactionClass, 0, len(policy.Emoticons)+len(policy.CustomEmojiIDs))
for _, emoticon := range policy.Emoticons {
if emoticon == "" {
continue
}
reactions = append(reactions, &tg.ReactionEmoji{Emoticon: emoticon})
}
for _, id := range policy.CustomEmojiIDs {
if id <= 0 {
continue
}
reactions = append(reactions, &tg.ReactionCustomEmoji{DocumentID: id})
}
return &tg.ChatReactionsSome{Reactions: reactions}
default:
return nil
}
}
func tgPeerColor(color domain.ChannelPeerColor) tg.PeerColorClass {
if color.Empty() {
return nil
}
out := &tg.PeerColor{}
if color.HasColor {
out.SetColor(color.Color)
}
if color.BackgroundEmojiID != 0 {
out.SetBackgroundEmojiID(color.BackgroundEmojiID)
}
return out
}
func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass {
switch member.Role {
case domain.ChannelRoleCreator:
out := &tg.ChannelParticipantCreator{
UserID: member.UserID,
AdminRights: tgChatAdminRights(member.AdminRights),
}
if member.Rank != "" {
out.SetRank(member.Rank)
}
return out
case domain.ChannelRoleAdmin:
out := &tg.ChannelParticipantAdmin{
Self: member.UserID == selfUserID,
UserID: member.UserID,
PromotedBy: member.InviterUserID,
Date: member.JoinedAt,
AdminRights: tgChatAdminRights(member.AdminRights),
}
if member.InviterUserID != 0 {
out.SetInviterID(member.InviterUserID)
}
if member.Rank != "" {
out.SetRank(member.Rank)
}
return out
default:
if member.Status != domain.ChannelMemberActive {
return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}}
}
if member.UserID == selfUserID {
out := &tg.ChannelParticipantSelf{
UserID: member.UserID,
InviterID: member.InviterUserID,
Date: member.JoinedAt,
}
if member.Rank != "" {
out.SetRank(member.Rank)
}
return out
}
out := &tg.ChannelParticipant{UserID: member.UserID, Date: member.JoinedAt}
if member.Rank != "" {
out.SetRank(member.Rank)
}
return out
}
}
func tgChannelAdminLogEvents(viewerUserID int64, events []domain.ChannelAdminLogEvent) []tg.ChannelAdminLogEvent {
out := make([]tg.ChannelAdminLogEvent, 0, len(events))
for _, event := range events {
if item, ok := tgChannelAdminLogEvent(viewerUserID, event); ok {
out = append(out, item)
}
}
return out
}
func tgChannelAdminLogEvent(viewerUserID int64, event domain.ChannelAdminLogEvent) (tg.ChannelAdminLogEvent, bool) {
action := tg.ChannelAdminLogEventActionClass(nil)
switch event.Type {
case domain.ChannelAdminLogChangeTitle:
action = &tg.ChannelAdminLogEventActionChangeTitle{PrevValue: event.PrevString, NewValue: event.NewString}
case domain.ChannelAdminLogChangeUsername:
action = &tg.ChannelAdminLogEventActionChangeUsername{PrevValue: event.PrevString, NewValue: event.NewString}
case domain.ChannelAdminLogChangeLinkedChat:
action = &tg.ChannelAdminLogEventActionChangeLinkedChat{PrevValue: int64(event.PrevInt), NewValue: int64(event.NewInt)}
case domain.ChannelAdminLogToggleSignatures:
action = &tg.ChannelAdminLogEventActionToggleSignatures{NewValue: event.NewBool}
case domain.ChannelAdminLogTogglePreHistoryHidden:
action = &tg.ChannelAdminLogEventActionTogglePreHistoryHidden{NewValue: event.NewBool}
case domain.ChannelAdminLogToggleForum:
action = &tg.ChannelAdminLogEventActionToggleForum{NewValue: event.NewBool}
case domain.ChannelAdminLogToggleAutotranslation:
action = &tg.ChannelAdminLogEventActionToggleAutotranslation{NewValue: event.NewBool}
case domain.ChannelAdminLogToggleAntiSpam:
action = &tg.ChannelAdminLogEventActionToggleAntiSpam{NewValue: event.NewBool}
case domain.ChannelAdminLogToggleSlowMode:
action = &tg.ChannelAdminLogEventActionToggleSlowMode{PrevValue: event.PrevInt, NewValue: event.NewInt}
case domain.ChannelAdminLogParticipantJoin:
action = &tg.ChannelAdminLogEventActionParticipantJoin{}
case domain.ChannelAdminLogParticipantLeave:
action = &tg.ChannelAdminLogEventActionParticipantLeave{}
case domain.ChannelAdminLogParticipantInvite:
if event.Participant == nil {
return tg.ChannelAdminLogEvent{}, false
}
action = &tg.ChannelAdminLogEventActionParticipantInvite{
Participant: tgChannelParticipantForUpdate(viewerUserID, *event.Participant),
}
case domain.ChannelAdminLogParticipantPromote, domain.ChannelAdminLogParticipantDemote:
if event.PrevParticipant == nil || event.NewParticipant == nil {
return tg.ChannelAdminLogEvent{}, false
}
action = &tg.ChannelAdminLogEventActionParticipantToggleAdmin{
PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant),
NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant),
}
case domain.ChannelAdminLogParticipantEditRank:
if event.Participant == nil {
return tg.ChannelAdminLogEvent{}, false
}
action = &tg.ChannelAdminLogEventActionParticipantEditRank{
UserID: event.Participant.UserID,
PrevRank: event.PrevString,
NewRank: event.NewString,
}
case domain.ChannelAdminLogParticipantBan, domain.ChannelAdminLogParticipantUnban, domain.ChannelAdminLogParticipantKick, domain.ChannelAdminLogParticipantUnkick:
if event.PrevParticipant == nil || event.NewParticipant == nil {
return tg.ChannelAdminLogEvent{}, false
}
action = &tg.ChannelAdminLogEventActionParticipantToggleBan{
PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant),
NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant),
}
case domain.ChannelAdminLogUpdatePinned:
action = &tg.ChannelAdminLogEventActionUpdatePinned{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)}
case domain.ChannelAdminLogSendMessage:
action = &tg.ChannelAdminLogEventActionSendMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)}
case domain.ChannelAdminLogEditMessage:
action = &tg.ChannelAdminLogEventActionEditMessage{
PrevMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.PrevMessage),
NewMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.NewMessage),
}
case domain.ChannelAdminLogDeleteMessage:
action = &tg.ChannelAdminLogEventActionDeleteMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)}
default:
return tg.ChannelAdminLogEvent{}, false
}
if action == nil {
return tg.ChannelAdminLogEvent{}, false
}
return tg.ChannelAdminLogEvent{
ID: event.ID,
Date: event.Date,
UserID: event.UserID,
Action: action,
}, true
}
func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage) tg.MessageClass {
if msg == nil || msg.ID == 0 {
out := &tg.MessageEmpty{ID: 0}
out.SetPeerID(&tg.PeerChannel{ChannelID: channelID})
return out
}
return tgChannelMessage(viewerUserID, *msg)
}
func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
Other: true,
ManageRanks: rights.ManageRanks,
// manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum,
// 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。
ManageDirectMessages: rights.ManageDirectMessages,
}
}
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
return domain.ChannelAdminRights{
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
ManageRanks: rights.ManageRanks,
ManageDirectMessages: rights.ManageDirectMessages,
}
}
func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
return tg.ChatBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
EditRank: rights.EditRank,
UntilDate: rights.UntilDate,
}
}
func tgDefaultChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
out := tgChatBannedRights(rights)
if out.UntilDate == 0 {
out.UntilDate = defaultChatBannedRightsUntilDate
}
return out
}
func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights {
return domain.ChannelBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
EditRank: rights.EditRank,
UntilDate: rights.UntilDate,
}
}
func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
return rights == domain.ChannelBannedRights{}
}

View file

@ -0,0 +1,69 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func TestTGChannelHasLinkFromInviteProjection(t *testing.T) {
channel := domain.Channel{
ID: 1001,
AccessHash: 42,
Title: "private group with link",
Megagroup: true,
HasLink: true,
Date: 1700000100,
}
self := &domain.ChannelMember{
ChannelID: channel.ID,
UserID: 10,
Status: domain.ChannelMemberActive,
Role: domain.ChannelRoleCreator,
}
got := tgChannel(10, channel, self)
if !got.GetHasLink() {
t.Fatalf("tgChannel.has_link = false, want true for linked private megagroup")
}
}
func TestTGChannelFullIncludesExportedInvite(t *testing.T) {
view := domain.ChannelView{
Channel: domain.Channel{
ID: 1002,
AccessHash: 43,
Title: "private group",
Megagroup: true,
Date: 1700000100,
},
Self: domain.ChannelMember{
ChannelID: 1002,
UserID: 10,
Status: domain.ChannelMemberActive,
Role: domain.ChannelRoleCreator,
},
ExportedInvite: &domain.ChannelInvite{
ChannelID: 1002,
InviteID: 77,
Hash: "abc123",
AdminUserID: 10,
Permanent: true,
Date: 1700000111,
},
}
full := tgChannelFull(view)
rawInvite, ok := full.GetExportedInvite()
if !ok {
t.Fatalf("channelFull.exported_invite missing")
}
invite, ok := rawInvite.(*tg.ChatInviteExported)
if !ok {
t.Fatalf("channelFull.exported_invite = %T, want *tg.ChatInviteExported", rawInvite)
}
if !invite.Permanent || invite.Revoked || invite.AdminID != 10 || invite.Link != "https://telesrv.net/+abc123" {
t.Fatalf("channelFull.exported_invite = %#v, want active permanent invite", invite)
}
}

View file

@ -0,0 +1,113 @@
package rpc
import (
"testing"
"telesrv/internal/domain"
)
// TestTgChannelMonoforumSuppressesCreatorChrome 锁定频道私信(monoforum)渲染修复的核心不变量:
// monoforum 虚拟频道在 TDesktop 必须呈现为 Direct-Messages 容器而非普通群。真机 bug 的根因是
// mono 自身被投影了 Creator=true —— TDesktop 的 NeedAboutGroup 对 megagroup 看 amCreator() 决定
// 画 "You created a group" 群聊空状态,Leave/订阅数 chrome 也 key off amCreator/asMegagroup。
// DM 容器身份(本地 MonoforumAdmin 标志)由客户端从母频道 canAccessMonoforum 派生,与 mono 自身无关。
func TestTgChannelMonoforumSuppressesCreatorChrome(t *testing.T) {
const owner = int64(1001)
const parentID = int64(5001)
const monoID = int64(5002)
mono := domain.Channel{
ID: monoID, CreatorUserID: owner, Title: "Parent",
Megagroup: true, Monoforum: true, LinkedMonoforumID: parentID,
// monoforum 镜像母频道 DM 启用状态;启用时才下发 linked_monoforum_id。
BroadcastMessagesAllowed: true,
}
// 即便带 creator 角色的 synthetic self(admin 预览),mono 自身对象也必须 Creator=false / 无 admin_rights。
syntheticSelf := &domain.ChannelMember{ChannelID: monoID, UserID: owner, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator}
monoTg := tgChannel(owner, mono, syntheticSelf)
if !monoTg.Megagroup || !monoTg.Monoforum {
t.Fatalf("mono megagroup=%v monoforum=%v, want both true", monoTg.Megagroup, monoTg.Monoforum)
}
if monoTg.Creator {
t.Fatalf("mono Creator=true, want false (Creator=true paints the wrong group chrome)")
}
if _, ok := monoTg.GetAdminRights(); ok {
t.Fatalf("mono must carry no admin_rights (admin status derived client-side from parent)")
}
if monoTg.Left {
t.Fatalf("mono Left=true, want false")
}
if id, ok := monoTg.GetLinkedMonoforumID(); !ok || id != parentID {
t.Fatalf("mono linked_monoforum_id = %d ok=%v, want parent %d", id, ok, parentID)
}
// 母广播频道对 owner 投影:broadcast-only + Creator=true(canAccessMonoforum 走 amCreator 旁路)
// + broadcast_messages_allowed + 反向 linked_monoforum_id。三者共同让客户端派生 MonoforumAdmin。
parent := domain.Channel{
ID: parentID, CreatorUserID: owner, Title: "Parent",
Broadcast: true, BroadcastMessagesAllowed: true, LinkedMonoforumID: monoID,
}
parentTg := tgChannel(owner, parent, nil)
if !parentTg.Broadcast || parentTg.Megagroup || parentTg.Monoforum {
t.Fatalf("parent broadcast=%v mega=%v mono=%v, want broadcast only", parentTg.Broadcast, parentTg.Megagroup, parentTg.Monoforum)
}
if !parentTg.Creator {
t.Fatalf("parent Creator=false for owner viewer, want true (canAccessMonoforum via amCreator)")
}
if !parentTg.BroadcastMessagesAllowed {
t.Fatalf("parent broadcast_messages_allowed=false, want true")
}
if id, ok := parentTg.GetLinkedMonoforumID(); !ok || id != monoID {
t.Fatalf("parent linked_monoforum_id = %d ok=%v, want mono %d", id, ok, monoID)
}
// 普通频道(Monoforum=false)不受影响:creator self 仍投影 Creator=true。
normal := domain.Channel{ID: 6001, CreatorUserID: owner, Title: "Normal", Megagroup: true}
normalTg := tgChannel(owner, normal, &domain.ChannelMember{ChannelID: 6001, UserID: owner, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator})
if !normalTg.Creator {
t.Fatalf("normal megagroup creator suppressed; monoforum guard leaked to non-monoforum channels")
}
}
// TestChannelAdminRightsManageDirectMessagesRoundTrip 证明新映射的 manage_direct_messages(flags.17)
// 双向无损,且对未设置该权限的普通管理员保持惰性(不污染线格式)。
func TestChannelAdminRightsManageDirectMessagesRoundTrip(t *testing.T) {
tgRights := tgChatAdminRights(domain.ChannelAdminRights{ManageDirectMessages: true, PostMessages: true})
if !tgRights.ManageDirectMessages {
t.Fatalf("tg ManageDirectMessages=false, want true")
}
if back := domainChannelAdminRights(tgRights); !back.ManageDirectMessages {
t.Fatalf("round-trip dropped ManageDirectMessages")
}
if plain := tgChatAdminRights(domain.ChannelAdminRights{PinMessages: true}); plain.ManageDirectMessages {
t.Fatalf("normal admin rights wrongly set ManageDirectMessages (not inert)")
}
}
// TestTgChannelMonoforumDisabledHidesLink 锁定关闭 Direct Messages 时的投影:母频道与 monoforum
// **双方都**隐藏 linked_monoforum_id(都以 BroadcastMessagesAllowed 表示 DM 启用,关闭时为 false)。
// monoforum 必须也隐藏 —— 否则用户打开 monoforum 重新拉取频道对象时,mono 仍带 link →
// setMonoforumLink(parent) 触发 link&&monoforumDisabled 分支把 MonoforumDisabled 清掉,「已停用私信」
// 停用页脚消失。monoforum 自身绝不投影 broadcast_messages_allowed(它是 megagroup)。
func TestTgChannelMonoforumDisabledHidesLink(t *testing.T) {
const owner = int64(1001)
const parentID = int64(5001)
const monoID = int64(5002)
parent := domain.Channel{ID: parentID, CreatorUserID: owner, Broadcast: true, LinkedMonoforumID: monoID, BroadcastMessagesAllowed: false}
if _, ok := tgChannel(owner, parent, nil).GetLinkedMonoforumID(); ok {
t.Fatalf("disabled parent projected linked_monoforum_id, want hidden")
}
mono := domain.Channel{ID: monoID, CreatorUserID: owner, Megagroup: true, Monoforum: true, LinkedMonoforumID: parentID, BroadcastMessagesAllowed: false}
monoTg := tgChannel(owner, mono, nil)
if _, ok := monoTg.GetLinkedMonoforumID(); ok {
t.Fatalf("disabled monoforum projected linked_monoforum_id, want hidden (else opening it clears MonoforumDisabled → footer disappears)")
}
if !monoTg.Monoforum || !monoTg.Megagroup {
t.Fatalf("disabled mono monoforum=%v mega=%v, want both still true", monoTg.Monoforum, monoTg.Megagroup)
}
if monoTg.BroadcastMessagesAllowed {
t.Fatalf("monoforum leaked broadcast_messages_allowed, want never projected (it is a megagroup)")
}
}

View file

@ -0,0 +1,329 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass {
dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)+1)
// dialogFolder 条目排在最前TDesktop 据它发现 archive folder 并渲染
// 归档行的未读徽章Folder::applyDialog / MainList::updateCloudUnread
if folder := tgDialogFolder(list.ArchiveSummary); folder != nil {
dialogs = append(dialogs, folder)
}
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
dialogs = append(dialogs, dialog)
}
}
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
messages = append(messages, item)
}
}
for _, msg := range list.ChannelMessages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
messages = append(messages, item)
}
}
users := tgUsersForViewer(viewerUserID, list.Users)
chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)
if list.Count > len(dialogs) {
return &tg.MessagesDialogsSlice{
Count: list.Count,
Dialogs: dialogs,
Messages: messages,
Chats: chats,
Users: users,
}
}
return &tg.MessagesDialogs{
Dialogs: dialogs,
Messages: messages,
Chats: chats,
Users: users,
}
}
func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs {
out := &tg.MessagesPeerDialogs{
Dialogs: make([]tg.DialogClass, 0, len(list.Dialogs)+1),
Messages: make([]tg.MessageClass, 0, len(list.Messages)+len(list.ChannelMessages)),
Users: make([]tg.UserClass, 0, len(list.Users)),
Chats: make([]tg.ChatClass, 0, len(list.Channels)),
State: tgUpdateState(st),
}
// getPinnedDialogs(folder_id=0) 必须带 dialogFolder 条目DrKLO 主列表
// getDialogs 一律 exclude_pinnedarchive 行只能从 pinned 响应发现
// fetchFolderInLoadedPinnedDialogs且要求 top_message/peer 非零)。
if folder := tgDialogFolder(list.ArchiveSummary); folder != nil {
out.Dialogs = append(out.Dialogs, folder)
}
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
out.Dialogs = append(out.Dialogs, dialog)
}
}
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
out.Messages = append(out.Messages, item)
}
}
for _, msg := range list.ChannelMessages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
out.Messages = append(out.Messages, item)
}
}
for _, u := range list.Users {
if viewerUserID != 0 && u.ID == viewerUserID {
out.Users = append(out.Users, tgSelfUser(u))
} else {
out.Users = append(out.Users, tgUser(u))
}
}
out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...)
return out
}
// tgDialogFolder 把归档摘要转成 dialogFolder#71bd134c 条目。当前未接
// per-peer mute 状态,未读计数全部归入 unmuted 桶TDesktop 用 unmuted
// 桶渲染亮色徽章muted 桶渲染灰色,全归 unmuted 只影响徽章颜色不丢计数)。
func tgDialogFolder(summary *domain.DialogArchiveSummary) tg.DialogClass {
if summary == nil {
return nil
}
peer := tgPeer(summary.TopPeer)
if peer == nil {
return nil
}
return &tg.DialogFolder{
Pinned: summary.Pinned,
Folder: tg.Folder{
ID: domain.DialogArchiveFolderID,
Title: "Archived Chats",
},
Peer: peer,
TopMessage: summary.TopMessage,
UnreadUnmutedPeersCount: summary.UnreadPeersCount,
UnreadUnmutedMessagesCount: summary.UnreadMessagesCount,
}
}
func tgDialog(d domain.Dialog) *tg.Dialog {
peer := tgPeer(d.Peer)
if peer == nil {
return nil
}
out := &tg.Dialog{
Pinned: d.Pinned,
UnreadMark: d.UnreadMark,
ViewForumAsMessages: d.ViewForumAsMessages,
Peer: peer,
TopMessage: d.TopMessage,
ReadInboxMaxID: d.ReadInboxMaxID,
ReadOutboxMaxID: d.ReadOutboxMaxID,
UnreadCount: d.UnreadCount,
UnreadMentionsCount: d.UnreadMentions,
UnreadReactionsCount: d.UnreadReactions,
NotifySettings: *tgPeerNotifySettings(d.NotifySettings),
}
if d.FolderID != domain.DialogMainFolderID {
out.SetFolderID(d.FolderID)
}
if d.TTLPeriod > 0 {
out.SetTTLPeriod(d.TTLPeriod)
}
if d.Peer.Type == domain.PeerTypeChannel && d.Pts > 0 {
// 客户端用 dialog.pts 初始化 channel 本地序列;缺失会让
// getChannelDifference 起点失效(冷启动 gap 不被发现)。
out.SetPts(d.Pts)
}
if d.Draft != nil {
out.SetDraft(tgDialogDraft(*d.Draft))
}
return out
}
func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
if d.Empty() {
out := &tg.DraftMessageEmpty{}
out.SetDate(d.Date)
return out
}
out := &tg.DraftMessage{
NoWebpage: d.NoWebpage,
InvertMedia: d.InvertMedia,
ReplyTo: tgDraftReplyTo(d),
Message: d.Message,
Entities: tgMessageEntities(d.Entities),
Media: tgDraftWebPage(d.WebPage),
Date: d.Date,
Effect: d.Effect,
}
return out
}
func tgDraftReplyTo(d domain.DialogDraft) tg.InputReplyToClass {
if d.ReplyTo == nil {
return nil
}
reply := &tg.InputReplyToMessage{ReplyToMsgID: d.ReplyTo.MessageID}
if d.ReplyTo.TopMessageID > 0 {
reply.SetTopMsgID(d.ReplyTo.TopMessageID)
}
if d.ReplyTo.QuoteText != "" {
reply.SetQuoteText(d.ReplyTo.QuoteText)
}
if len(d.ReplyTo.QuoteEntities) > 0 {
reply.SetQuoteEntities(tgMessageEntities(d.ReplyTo.QuoteEntities))
}
if d.ReplyTo.QuoteOffset > 0 {
reply.SetQuoteOffset(d.ReplyTo.QuoteOffset)
}
return reply
}
func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass {
if webpage == nil || webpage.URL == "" {
return nil
}
return &tg.InputMediaWebPage{
ForceLargeMedia: webpage.ForceLargeMedia,
ForceSmallMedia: webpage.ForceSmallMedia,
Optional: webpage.Optional,
URL: webpage.URL,
}
}
func tgDialogPeer(p domain.Peer) tg.DialogPeerClass {
peer := tgPeer(p)
if peer == nil {
return nil
}
return &tg.DialogPeer{Peer: peer}
}
func tgDialogPeers(peers []domain.Peer) []tg.DialogPeerClass {
out := make([]tg.DialogPeerClass, 0, len(peers))
for _, peer := range peers {
item := tgDialogPeer(peer)
if item != nil {
out = append(out, item)
}
}
return out
}
func tgDialogFilters(list domain.DialogFolderList) *tg.MessagesDialogFilters {
filters := make([]tg.DialogFilterClass, 0, len(list.Folders)+1)
filters = append(filters, &tg.DialogFilterDefault{})
for _, folder := range list.Folders {
if item := tgDialogFilter(folder); item != nil {
filters = append(filters, item)
}
}
return &tg.MessagesDialogFilters{TagsEnabled: list.TagsEnabled, Filters: filters}
}
func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass {
title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)}
if folder.IsChatlist {
out := &tg.DialogFilterChatlist{
TitleNoanimate: folder.TitleNoanimate,
ID: folder.ID,
Title: title,
PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers),
IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers),
}
if folder.HasEmoticon {
out.SetEmoticon(folder.Emoticon)
}
if folder.HasColor {
out.SetColor(folder.Color)
}
return out
}
out := &tg.DialogFilter{
Contacts: folder.Contacts,
NonContacts: folder.NonContacts,
Groups: folder.Groups,
Broadcasts: folder.Broadcasts,
Bots: folder.Bots,
ExcludeMuted: folder.ExcludeMuted,
ExcludeRead: folder.ExcludeRead,
ExcludeArchived: folder.ExcludeArchived,
TitleNoanimate: folder.TitleNoanimate,
ID: folder.ID,
Title: title,
PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers),
IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers),
ExcludePeers: tgDialogFolderInputPeers(folder.ExcludePeers),
}
if folder.HasEmoticon {
out.SetEmoticon(folder.Emoticon)
}
if folder.HasColor {
out.SetColor(folder.Color)
}
return out
}
func tgDialogFolderInputPeers(peers []domain.DialogFolderPeer) []tg.InputPeerClass {
if len(peers) == 0 {
return nil
}
out := make([]tg.InputPeerClass, 0, len(peers))
for _, item := range peers {
switch item.Peer.Type {
case domain.PeerTypeUser:
out = append(out, &tg.InputPeerUser{UserID: item.Peer.ID, AccessHash: item.AccessHash})
case domain.PeerTypeChannel:
out = append(out, &tg.InputPeerChannel{ChannelID: item.Peer.ID, AccessHash: item.AccessHash})
}
}
return out
}
func tgFolderPeers(peers []domain.FolderPeerUpdate) []tg.FolderPeer {
out := make([]tg.FolderPeer, 0, len(peers))
for _, item := range peers {
peer := tgPeer(item.Peer)
if peer == nil {
continue
}
out = append(out, tg.FolderPeer{Peer: peer, FolderID: item.FolderID})
}
return out
}
func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs []domain.Dialog) []tg.ChatClass {
left := make(map[int64]struct{})
for _, dialog := range dialogs {
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 && dialog.ChannelLeft {
left[dialog.Peer.ID] = struct{}{}
}
}
out := make([]tg.ChatClass, 0, len(channels))
seen := make(map[int64]struct{}, len(channels))
for _, ch := range channels {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
var self *domain.ChannelMember
if _, ok := left[ch.ID]; ok {
self = &domain.ChannelMember{
ChannelID: ch.ID,
UserID: viewerUserID,
Status: domain.ChannelMemberLeft,
}
}
out = append(out, tgChannelChat(viewerUserID, ch, self))
}
return out
}

View file

@ -0,0 +1,116 @@
package rpc
import (
"encoding/binary"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// 密聊Secret Chatdomain ↔ tg 投影。服务端是盲中继g_a/g_b/key_fingerprint
// 原样回放。chat 双视角不对称(见 docs/secret-chat-module.md §3.6
// - access_hash 双方不同(按 viewer 取 admin/participant 列)。
// - GAOrB 对 admin 视角是 g_b、对 participant 视角是 g_aTL 注释钉死)。
// - pending 态admin 看 encryptedChatWaiting无 g_aparticipant 看
// encryptedChatRequested携 g_a
// deviceAuthKeyBytes 把绑定维度的 int64 auth_key_id 转回 [8]byte用于 SessionManager
// 按业务 auth_key 定向投递)。与 businessAuthKeyInt64 互逆(小端)。
func deviceAuthKeyBytes(id int64) [8]byte {
var b [8]byte
binary.LittleEndian.PutUint64(b[:], uint64(id))
return b
}
// tgEncryptedMessage 把 qts 队列里的不透明加密消息投影为 TL EncryptedMessage(Service)。
// 盲中继Bytes 原样回放,不解密。
func tgEncryptedMessage(m domain.SecretChatMessage) tg.EncryptedMessageClass {
if m.IsService {
return &tg.EncryptedMessageService{
RandomID: m.RandomID,
ChatID: m.ChatID,
Date: m.Date,
Bytes: append([]byte(nil), m.Bytes...),
}
}
return &tg.EncryptedMessage{
RandomID: m.RandomID,
ChatID: m.ChatID,
Date: m.Date,
Bytes: append([]byte(nil), m.Bytes...),
File: tgEncryptedFile(m.File),
}
}
// tgEncryptedFile 把密聊文件快照投影为 TL EncryptedFile(Empty)。
func tgEncryptedFile(ref *domain.EncryptedFileRef) tg.EncryptedFileClass {
if ref == nil || ref.ID == 0 {
return &tg.EncryptedFileEmpty{}
}
return &tg.EncryptedFile{
ID: ref.ID,
AccessHash: ref.AccessHash,
Size: ref.Size,
DCID: ref.DCID,
KeyFingerprint: ref.KeyFingerprint,
}
}
// businessAuthKeyInt64 把 ctx 业务视角 auth_key_id[8]byte转成 DB/绑定维度的
// int64。必须与 store/postgres authKeyIDToInt64 同源小端MTProto 即 SHA1 低 64 位),
// 否则密聊绑定键与 update_states/authorizations 对不上。一致性见
// convert_encrypted_test.go。
func businessAuthKeyInt64(id [8]byte) int64 {
return int64(binary.LittleEndian.Uint64(id[:]))
}
// tgEncryptedChatForViewer 把密聊投影为 viewerID 视角的 EncryptedChatClass。
func tgEncryptedChatForViewer(chat domain.SecretChat, viewerID int64) tg.EncryptedChatClass {
if chat.Terminal() {
return &tg.EncryptedChatDiscarded{
HistoryDeleted: chat.HistoryDeleted,
ID: chat.ID,
}
}
accessHash := chat.AccessHashFor(viewerID)
if chat.State == domain.SecretChatStateNormal {
// GAOrBadmin 看 g_b、participant 看 g_a。
gaOrB := chat.GA
if chat.IsAdmin(viewerID) {
gaOrB = chat.GB
}
return &tg.EncryptedChat{
ID: chat.ID,
AccessHash: accessHash,
Date: chat.Date,
AdminID: chat.AdminUserID,
ParticipantID: chat.ParticipantUserID,
GAOrB: append([]byte(nil), gaOrB...),
KeyFingerprint: chat.KeyFingerprint,
}
}
// requested 态admin 视角 waiting无 g_aparticipant 视角 requested携 g_a
if chat.IsAdmin(viewerID) {
return &tg.EncryptedChatWaiting{
ID: chat.ID,
AccessHash: accessHash,
Date: chat.Date,
AdminID: chat.AdminUserID,
ParticipantID: chat.ParticipantUserID,
}
}
requested := &tg.EncryptedChatRequested{
ID: chat.ID,
AccessHash: accessHash,
Date: chat.Date,
AdminID: chat.AdminUserID,
ParticipantID: chat.ParticipantUserID,
GA: append([]byte(nil), chat.GA...),
}
if chat.FolderID != 0 {
requested.FolderID = chat.FolderID
requested.Flags.Set(0)
}
return requested
}

View file

@ -0,0 +1,98 @@
package rpc
import (
"bytes"
"encoding/binary"
"testing"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// TestBusinessAuthKeyInt64 钉死 [8]byte→int64 与 store/postgres authKeyIDToInt64
// 同源小端MTProto 即 SHA1 低 64 位)。两者漂移会让密聊绑定键与
// update_states/authorizations 对不上。
func TestBusinessAuthKeyInt64(t *testing.T) {
cases := [][8]byte{
{1, 0, 0, 0, 0, 0, 0, 0},
{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff},
{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04},
}
for _, id := range cases {
want := int64(binary.LittleEndian.Uint64(id[:]))
if got := businessAuthKeyInt64(id); got != want {
t.Fatalf("businessAuthKeyInt64(%v) = %d, want %d (little-endian, 同 authKeyIDToInt64)", id, got, want)
}
}
}
func baseChat() domain.SecretChat {
return domain.SecretChat{
ID: 7,
AdminUserID: 1,
ParticipantUserID: 2,
AdminAccessHash: 111,
ParticipantAccessHash: 222,
GA: []byte{0x0a, 0x0b},
GB: []byte{0x0c, 0x0d},
KeyFingerprint: 99,
Date: 1000,
}
}
func TestEncryptedChatForViewerRequested(t *testing.T) {
chat := baseChat()
chat.State = domain.SecretChatStateRequested
// admin 视角encryptedChatWaiting无 g_aaccess_hash=admin。
adminView, ok := tgEncryptedChatForViewer(chat, 1).(*tg.EncryptedChatWaiting)
if !ok {
t.Fatalf("admin view type = %T, want *tg.EncryptedChatWaiting", tgEncryptedChatForViewer(chat, 1))
}
if adminView.ID != 7 || adminView.AccessHash != 111 || adminView.AdminID != 1 || adminView.ParticipantID != 2 {
t.Fatalf("admin waiting view = %+v", adminView)
}
// participant 视角encryptedChatRequested携 g_aaccess_hash=participant。
partView, ok := tgEncryptedChatForViewer(chat, 2).(*tg.EncryptedChatRequested)
if !ok {
t.Fatalf("participant view type = %T, want *tg.EncryptedChatRequested", tgEncryptedChatForViewer(chat, 2))
}
if partView.AccessHash != 222 || !bytes.Equal(partView.GA, chat.GA) {
t.Fatalf("participant requested view = %+v", partView)
}
}
func TestEncryptedChatForViewerNormal(t *testing.T) {
chat := baseChat()
chat.State = domain.SecretChatStateNormal
// admin 视角GAOrB = g_b。
adminView, ok := tgEncryptedChatForViewer(chat, 1).(*tg.EncryptedChat)
if !ok {
t.Fatalf("admin view type = %T, want *tg.EncryptedChat", tgEncryptedChatForViewer(chat, 1))
}
if adminView.AccessHash != 111 || !bytes.Equal(adminView.GAOrB, chat.GB) || adminView.KeyFingerprint != 99 {
t.Fatalf("admin normal view = %+v (GAOrB must be g_b)", adminView)
}
// participant 视角GAOrB = g_a。
partView := tgEncryptedChatForViewer(chat, 2).(*tg.EncryptedChat)
if partView.AccessHash != 222 || !bytes.Equal(partView.GAOrB, chat.GA) {
t.Fatalf("participant normal view = %+v (GAOrB must be g_a)", partView)
}
}
func TestEncryptedChatForViewerDiscarded(t *testing.T) {
chat := baseChat()
chat.State = domain.SecretChatStateDiscarded
chat.HistoryDeleted = true
view, ok := tgEncryptedChatForViewer(chat, 1).(*tg.EncryptedChatDiscarded)
if !ok {
t.Fatalf("discarded view type = %T", tgEncryptedChatForViewer(chat, 1))
}
if view.ID != 7 || !view.HistoryDeleted {
t.Fatalf("discarded view = %+v", view)
}
}

View file

@ -0,0 +1,125 @@
package rpc
import (
"errors"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"telesrv/internal/domain"
)
// P3 reply_markup 错误码(对齐官方)。
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") }
// replyMarkupErr 把 domain 校验错误映射为客户端错误码。
func replyMarkupErr(err error) error {
switch {
case errors.Is(err, domain.ErrButtonDataInvalid):
return buttonDataInvalidErr()
case errors.Is(err, domain.ErrButtonURLInvalid):
return buttonURLInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid):
return buttonInvalidErr()
default:
return replyMarkupInvalidErr()
}
}
// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义:
// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil不报错——对齐
// 官方「普通用户 markup 无效」I1
// - 仅 ReplyInlineMarkup 被处理reply keyboard 家族(自定义键盘/隐藏/强制回复)
// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4
// - inline 行内按钮仅 callback / url其它按钮类型webview/game/url_auth/
// request_* 等)→ ErrButtonTypeInvalid拒绝整条发送绝不半实现下发
// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。
func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) {
if markup == nil || !senderIsBot {
return nil, nil
}
inline, ok := markup.(*tg.ReplyInlineMarkup)
if !ok {
// reply keyboard / hide / force-replyP3 不支持,丢弃。
return nil, nil
}
parsed, err := domainInlineMarkup(inline)
if err != nil {
return nil, err
}
if parsed.IsZero() {
return nil, nil
}
if err := domain.ValidateReplyMarkup(parsed); err != nil {
return nil, err
}
return parsed, nil
}
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
for _, row := range inline.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, btn := range row.Buttons {
db, err := domainMarkupButton(btn)
if err != nil {
return nil, err
}
domainRow = append(domainRow, db)
}
out.Inline = append(out.Inline, domainRow)
}
return out, nil
}
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
switch b := btn.(type) {
case *tg.KeyboardButtonCallback:
return domain.MarkupButton{
Type: domain.MarkupButtonCallback,
Text: b.Text,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
}, nil
case *tg.KeyboardButtonURL:
return domain.MarkupButton{
Type: domain.MarkupButtonURL,
Text: b.Text,
URL: b.URL,
}, nil
default:
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
}
// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。
func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
if m.IsZero() {
return nil
}
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
for _, row := range m.Inline {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
for _, btn := range row {
buttons = append(buttons, tgMarkupButton(btn))
}
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
}
return &tg.ReplyInlineMarkup{Rows: rows}
}
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
switch btn.Type {
case domain.MarkupButtonURL:
return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
default: // callback
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
if btn.RequiresPassword {
out.SetRequiresPassword(true)
}
return out
}
}

View file

@ -1,6 +1,8 @@
package rpc
import (
"strings"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
@ -39,11 +41,167 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
out.TTLSeconds = m.TTLSeconds
}
return out
case domain.MessageMediaKindContact:
if m.Contact == nil {
return &tg.MessageMediaEmpty{}
}
return &tg.MessageMediaContact{
PhoneNumber: m.Contact.PhoneNumber,
FirstName: m.Contact.FirstName,
LastName: m.Contact.LastName,
Vcard: m.Contact.Vcard,
UserID: m.Contact.UserID,
}
case domain.MessageMediaKindGeo:
if m.Geo == nil {
return &tg.MessageMediaEmpty{}
}
return &tg.MessageMediaGeo{Geo: tgGeoPoint(*m.Geo)}
case domain.MessageMediaKindVenue:
if m.Venue == nil {
return &tg.MessageMediaEmpty{}
}
return &tg.MessageMediaVenue{
Geo: tgGeoPoint(m.Venue.Geo),
Title: m.Venue.Title,
Address: m.Venue.Address,
Provider: m.Venue.Provider,
VenueID: m.Venue.VenueID,
VenueType: m.Venue.VenueType,
}
case domain.MessageMediaKindDice:
if m.Dice == nil {
return &tg.MessageMediaEmpty{}
}
return &tg.MessageMediaDice{Value: m.Dice.Value, Emoticon: m.Dice.Emoticon}
case domain.MessageMediaKindPoll:
if m.Poll == nil {
return &tg.MessageMediaEmpty{}
}
out := &tg.MessageMediaPoll{Poll: tgPoll(*m.Poll), Results: tgPollResults(*m.Poll)}
if m.Poll.AttachedMedia != nil {
out.SetAttachedMedia(tgMessageMedia(m.Poll.AttachedMedia))
}
return out
case domain.MessageMediaKindTodo:
if m.Todo == nil {
return &tg.MessageMediaEmpty{}
}
return tgTodoMedia(*m.Todo)
case domain.MessageMediaKindGeoLive:
if m.GeoLive == nil {
return &tg.MessageMediaEmpty{}
}
out := &tg.MessageMediaGeoLive{
Geo: tgGeoPoint(m.GeoLive.Geo),
Period: m.GeoLive.Period,
}
if m.GeoLive.Heading > 0 {
out.SetHeading(m.GeoLive.Heading)
}
if m.GeoLive.ProximityNotificationRadius > 0 {
out.SetProximityNotificationRadius(m.GeoLive.ProximityNotificationRadius)
}
return out
case domain.MessageMediaKindStory:
if m.Story == nil {
return &tg.MessageMediaEmpty{}
}
peer := tgPeer(m.Story.Peer)
if peer == nil {
return &tg.MessageMediaEmpty{}
}
out := &tg.MessageMediaStory{
ViaMention: m.Story.ViaMention,
Peer: peer,
ID: m.Story.ID,
}
if m.Story.Story != nil {
out.SetStory(tgStoryItem(*m.Story.Story))
}
return out
case domain.MessageMediaKindWebPage:
if m.WebPage == nil {
return &tg.MessageMediaEmpty{}
}
return tgWebPageMedia(*m.WebPage)
default:
return &tg.MessageMediaEmpty{}
}
}
// tgWebPageMedia 把链接预览快照转成 messageMediaWebPage外层 wrapper 携带
// force_large/force_small/manual/safe 标志,内层按 state 投影为
// webPagePending / webPage / webPageEmpty。
func tgWebPageMedia(w domain.MessageWebPage) tg.MessageMediaClass {
return &tg.MessageMediaWebPage{
ForceLargeMedia: w.ForceLargeMedia,
ForceSmallMedia: w.ForceSmallMedia,
Manual: w.Manual,
Safe: w.Safe,
Webpage: tgWebPage(w),
}
}
// tgWebPage 按链接预览快照的 state 投影出对应的 tg.WebPageClass。done 形态必须
// 始终填齐非可选的 id/url/display_url/hash否则客户端不渲染卡片。
func tgWebPage(w domain.MessageWebPage) tg.WebPageClass {
switch w.State {
case domain.MessageWebPageStateDone:
page := &tg.WebPage{
ID: w.ID,
URL: w.URL,
DisplayURL: w.DisplayURL,
Hash: w.Hash,
HasLargeMedia: w.HasLargeMedia,
}
if w.Type != "" {
page.SetType(w.Type)
}
if w.SiteName != "" {
page.SetSiteName(w.SiteName)
}
if w.Title != "" {
page.SetTitle(w.Title)
}
if w.Description != "" {
page.SetDescription(w.Description)
}
if w.Author != "" {
page.SetAuthor(w.Author)
}
if w.Photo != nil {
if photo := tgPhoto(*w.Photo); photo != nil {
if _, empty := photo.(*tg.PhotoEmpty); !empty {
page.SetPhoto(photo)
}
}
}
return page
case domain.MessageWebPageStateEmpty:
page := &tg.WebPageEmpty{ID: w.ID}
if w.URL != "" {
page.SetURL(w.URL)
}
return page
default: // pendingwebPagePending{id,date}url 可选但客户端需它展示待解析链接。
page := &tg.WebPagePending{ID: w.ID, Date: w.Date}
if w.URL != "" {
page.SetURL(w.URL)
}
return page
}
}
// tgGeoPoint 把 domain 坐标点转成 tg.GeoPoint。
func tgGeoPoint(g domain.MessageGeoPoint) tg.GeoPointClass {
out := &tg.GeoPoint{Lat: g.Lat, Long: g.Long, AccessHash: g.AccessHash}
if g.AccuracyRadius > 0 {
out.SetAccuracyRadius(g.AccuracyRadius)
}
return out
}
// tgChatPhoto 由 domain.Channel 反范式头像字段构造 ChatPhoto频道/群头像缩略)。
func tgChatPhoto(ch domain.Channel) tg.ChatPhotoClass {
if ch.PhotoID == 0 {
@ -81,12 +239,17 @@ func tgPhoto(p domain.Photo) tg.PhotoClass {
if p.ID == 0 {
return &tg.PhotoEmpty{}
}
sizes := tgPhotoSizes(p.Sizes)
if len(sizes) == 0 {
return &tg.PhotoEmpty{}
}
return &tg.Photo{
ID: p.ID,
AccessHash: p.AccessHash,
FileReference: p.FileReference,
Date: p.Date,
Sizes: tgPhotoSizes(p.Sizes),
Sizes: sizes,
VideoSizes: tgPhotoVideoSizes(p.Sizes),
DCID: p.DCID,
HasStickers: p.HasStickers,
}
@ -145,6 +308,9 @@ func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
}
out := make([]tg.PhotoSizeClass, 0, len(sizes))
for _, s := range sizes {
if isPhotoVideoSize(s.Kind) {
continue
}
out = append(out, tgPhotoSize(s))
}
return compactPhotoSizeClasses(out)
@ -167,6 +333,71 @@ func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass {
}
}
func tgPhotoVideoSizes(sizes []domain.PhotoSize) []tg.VideoSizeClass {
if len(sizes) == 0 {
return nil
}
out := make([]tg.VideoSizeClass, 0, len(sizes))
for _, s := range sizes {
switch s.Kind {
case domain.PhotoSizeKindVideo:
v := &tg.VideoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size}
if s.VideoStartTs != 0 {
v.SetVideoStartTs(s.VideoStartTs)
}
out = append(out, v)
case domain.PhotoSizeKindVideoEmojiMarkup:
out = append(out, &tg.VideoSizeEmojiMarkup{
EmojiID: s.EmojiID,
BackgroundColors: append([]int(nil), s.BackgroundColors...),
})
case domain.PhotoSizeKindVideoStickerMarkup:
out = append(out, &tg.VideoSizeStickerMarkup{
Stickerset: tgInputStickerSetFromPhotoSize(s),
StickerID: s.StickerID,
BackgroundColors: append([]int(nil), s.BackgroundColors...),
})
}
}
if len(out) == 0 {
return nil
}
return out
}
func tgInputStickerSetFromPhotoSize(s domain.PhotoSize) tg.InputStickerSetClass {
if s.StickerSetID != 0 {
return &tg.InputStickerSetID{ID: s.StickerSetID, AccessHash: s.StickerSetAccessHash}
}
if s.StickerSetShortName != "" {
return &tg.InputStickerSetShortName{ShortName: s.StickerSetShortName}
}
if input, ok := tgInputStickerSetFromSystemKey(s.StickerSetSystemKey); ok {
return input
}
return &tg.InputStickerSetEmpty{}
}
func tgInputStickerSetFromSystemKey(systemKey string) (tg.InputStickerSetClass, bool) {
switch systemKey {
case "animated_emoji":
return &tg.InputStickerSetAnimatedEmoji{}, true
case "animated_emoji_animations":
return &tg.InputStickerSetAnimatedEmojiAnimations{}, true
case "emoji_generic_animations":
return &tg.InputStickerSetEmojiGenericAnimations{}, true
default:
if strings.HasPrefix(systemKey, "dice:") {
return &tg.InputStickerSetDice{Emoticon: strings.TrimPrefix(systemKey, "dice:")}, true
}
return nil, false
}
}
func isPhotoVideoSize(kind domain.PhotoSizeKind) bool {
return kind == domain.PhotoSizeKindVideo || kind == domain.PhotoSizeKindVideoEmojiMarkup || kind == domain.PhotoSizeKindVideoStickerMarkup
}
func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass {
out := in[:0]
for _, s := range in {
@ -281,6 +512,64 @@ func reactionDocumentIDs(reactions []domain.AvailableReaction) []int64 {
return out
}
// tgAvailableEffects 构造 messages.availableEffects:effects 引用文档 id,文档对象在
// 独立 Documents 数组(去重),docByID 由 handler 预加载。
func tgAvailableEffects(effects []domain.AvailableEffect, docByID map[int64]domain.Document, hash int) *tg.MessagesAvailableEffects {
out := &tg.MessagesAvailableEffects{
Hash: hash,
Effects: make([]tg.AvailableEffect, 0, len(effects)),
Documents: []tg.DocumentClass{},
}
seenDoc := make(map[int64]struct{})
addDoc := func(id int64) {
if id == 0 {
return
}
if _, ok := seenDoc[id]; ok {
return
}
if d, ok := docByID[id]; ok {
out.Documents = append(out.Documents, tgDocument(d))
seenDoc[id] = struct{}{}
}
}
for _, e := range effects {
eff := tg.AvailableEffect{
ID: e.ID,
Emoticon: e.Emoticon,
EffectStickerID: e.EffectStickerID,
PremiumRequired: e.PremiumRequired,
}
if e.StaticIconID != 0 {
eff.SetStaticIconID(e.StaticIconID)
}
if e.EffectAnimationID != 0 {
eff.SetEffectAnimationID(e.EffectAnimationID)
}
out.Effects = append(out.Effects, eff)
addDoc(e.StaticIconID)
addDoc(e.EffectStickerID)
addDoc(e.EffectAnimationID)
}
return out
}
// effectDocumentIDs 收集一组 effect 引用的全部文档 id用于批量预加载
func effectDocumentIDs(effects []domain.AvailableEffect) []int64 {
seen := make(map[int64]struct{})
out := make([]int64, 0, len(effects)*3)
for _, e := range effects {
for _, id := range e.DocumentIDs() {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
}
return out
}
// ---- sticker sets ----
func tgStickerSet(set domain.StickerSet) tg.StickerSet {
@ -367,6 +656,8 @@ func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji_animations"}, true
case *tg.InputStickerSetEmojiGenericAnimations:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, true
case *tg.InputStickerSetEmojiDefaultStatuses:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses}, true
case *tg.InputStickerSetDice:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true
default:

View file

@ -0,0 +1,167 @@
package rpc
import (
"encoding/json"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// jsonRoundTripMedia 复刻 postgres 媒体 codecjson.Marshal/Unmarshal以验证
// 新增的 web_page 字段经 JSONB 列字节级 round-trip 后 Kind 与载荷不丢失。
func jsonRoundTripMedia(t *testing.T, m *domain.MessageMedia) *domain.MessageMedia {
t.Helper()
raw, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal media: %v", err)
}
var out domain.MessageMedia
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal media: %v", err)
}
if out.IsZero() {
t.Fatalf("web_page media decoded as zero (Kind lost): %s", raw)
}
return &out
}
// TestTgMessageMediaWebPageDone 验证 done 形态投影为 messageMediaWebPage{webPage}
// 字段齐全且能二进制编码(捕获非可选 id/url/display_url/hash 缺失)。
func TestTgMessageMediaWebPageDone(t *testing.T) {
src := &domain.MessageMedia{
Kind: domain.MessageMediaKindWebPage,
WebPage: &domain.MessageWebPage{
State: domain.MessageWebPageStateDone,
ID: 0x1234abcd,
URL: "https://example.com/article",
DisplayURL: "example.com",
Hash: 42,
Type: "article",
SiteName: "Example",
Title: "An Example Article",
Description: "A description of the article.",
Author: "Jane Doe",
ForceLargeMedia: true,
HasLargeMedia: true,
Photo: &domain.Photo{
ID: 0x99,
AccessHash: 0x55,
DCID: 2,
Sizes: []domain.PhotoSize{
{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 1280, H: 720, Size: 4096},
},
},
},
}
got := tgMessageMedia(jsonRoundTripMedia(t, src))
wrap, ok := got.(*tg.MessageMediaWebPage)
if !ok {
t.Fatalf("tgMessageMedia = %T, want *tg.MessageMediaWebPage", got)
}
if !wrap.ForceLargeMedia {
t.Errorf("ForceLargeMedia = false, want true")
}
page, ok := wrap.Webpage.(*tg.WebPage)
if !ok {
t.Fatalf("Webpage = %T, want *tg.WebPage", wrap.Webpage)
}
if page.ID != src.WebPage.ID || page.URL != src.WebPage.URL || page.DisplayURL != src.WebPage.DisplayURL || page.Hash != src.WebPage.Hash {
t.Errorf("non-optional fields mismatch: %+v", page)
}
if !page.HasLargeMedia {
t.Errorf("HasLargeMedia = false, want true")
}
if v, ok := page.GetSiteName(); !ok || v != "Example" {
t.Errorf("SiteName = %q (ok=%v), want Example", v, ok)
}
if v, ok := page.GetTitle(); !ok || v != "An Example Article" {
t.Errorf("Title = %q (ok=%v)", v, ok)
}
if v, ok := page.GetAuthor(); !ok || v != "Jane Doe" {
t.Errorf("Author = %q (ok=%v)", v, ok)
}
if photo, ok := page.GetPhoto(); !ok {
t.Errorf("GetPhoto ok=false, want embedded photo")
} else if _, isReal := photo.(*tg.Photo); !isReal {
t.Errorf("GetPhoto = %T, want *tg.Photo", photo)
}
// 二进制编码确认非可选字段齐全messageMediaWebPage.EncodeBare 会在 webpage
// 为 nil 或缺非可选字段时报错)。
var buf bin.Buffer
if err := wrap.Encode(&buf); err != nil {
t.Fatalf("encode messageMediaWebPage: %v", err)
}
}
// TestTgMessageMediaWebPagePending 验证 pending 形态投影为 webPagePending{id,url,date}。
func TestTgMessageMediaWebPagePending(t *testing.T) {
src := &domain.MessageMedia{
Kind: domain.MessageMediaKindWebPage,
WebPage: &domain.MessageWebPage{
State: domain.MessageWebPageStatePending,
ID: 777,
URL: "https://pending.example/x",
Date: 1700000000,
},
}
got := tgMessageMedia(jsonRoundTripMedia(t, src))
wrap, ok := got.(*tg.MessageMediaWebPage)
if !ok {
t.Fatalf("tgMessageMedia = %T, want *tg.MessageMediaWebPage", got)
}
pending, ok := wrap.Webpage.(*tg.WebPagePending)
if !ok {
t.Fatalf("Webpage = %T, want *tg.WebPagePending", wrap.Webpage)
}
if pending.ID != 777 || pending.Date != 1700000000 {
t.Errorf("pending = %+v, want id=777 date=1700000000", pending)
}
if v, ok := pending.GetURL(); !ok || v != src.WebPage.URL {
t.Errorf("pending URL = %q (ok=%v)", v, ok)
}
var buf bin.Buffer
if err := wrap.Encode(&buf); err != nil {
t.Fatalf("encode pending: %v", err)
}
}
// TestTgMessageMediaWebPageEmpty 验证 empty 形态投影为 webPageEmpty{id}。
func TestTgMessageMediaWebPageEmpty(t *testing.T) {
src := &domain.MessageMedia{
Kind: domain.MessageMediaKindWebPage,
WebPage: &domain.MessageWebPage{
State: domain.MessageWebPageStateEmpty,
ID: 555,
URL: "https://empty.example/y",
},
}
got := tgMessageMedia(jsonRoundTripMedia(t, src))
wrap, ok := got.(*tg.MessageMediaWebPage)
if !ok {
t.Fatalf("tgMessageMedia = %T, want *tg.MessageMediaWebPage", got)
}
empty, ok := wrap.Webpage.(*tg.WebPageEmpty)
if !ok {
t.Fatalf("Webpage = %T, want *tg.WebPageEmpty", wrap.Webpage)
}
if empty.ID != 555 {
t.Errorf("empty.ID = %d, want 555", empty.ID)
}
var buf bin.Buffer
if err := wrap.Encode(&buf); err != nil {
t.Fatalf("encode empty: %v", err)
}
}
// TestTgMessageMediaWebPageNilGuards 验证 nil WebPage 不会 panic 且回退空媒体。
func TestTgMessageMediaWebPageNilGuards(t *testing.T) {
got := tgMessageMedia(&domain.MessageMedia{Kind: domain.MessageMediaKindWebPage})
if _, ok := got.(*tg.MessageMediaEmpty); !ok {
t.Fatalf("nil WebPage = %T, want *tg.MessageMediaEmpty", got)
}
}

View file

@ -0,0 +1,570 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func tgMessagesMessages(viewerUserID int64, list domain.MessageList) tg.MessagesMessagesClass {
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
messages = append(messages, item)
}
}
users := tgUsersForViewer(viewerUserID, list.Users)
if list.Count > len(messages) {
return &tg.MessagesMessagesSlice{
Count: list.Count,
Messages: messages,
Users: users,
}
}
return &tg.MessagesMessages{
Messages: messages,
Users: users,
}
}
func tgMessage(m domain.Message) tg.MessageClass {
peer := tgPeer(m.Peer)
if peer == nil || m.ID == 0 {
return nil
}
if action := tgMessageServiceAction(m); action != nil {
msg := &tg.MessageService{
Out: m.Out,
MediaUnread: m.MediaUnread,
Silent: m.Silent,
ID: m.ID,
PeerID: peer,
Date: m.Date,
Action: action,
}
if from := tgPeer(m.From); from != nil {
msg.FromID = from
}
if reply := tgMessageReplyHeader(m); reply != nil {
msg.SetReplyTo(reply)
}
if m.TTLPeriod > 0 {
msg.SetTTLPeriod(m.TTLPeriod)
}
if reactions := tgMessageReactions(m.OwnerUserID, m.Reactions); reactions != nil {
msg.SetReactions(*reactions)
}
return msg
}
msg := &tg.Message{
Out: m.Out,
MediaUnread: m.MediaUnread,
ID: m.ID,
PeerID: peer,
Date: m.Date,
Message: m.Body,
Entities: tgMessageEntities(m.Entities),
}
if m.EditDate != 0 {
msg.SetEditDate(m.EditDate)
}
if m.Silent {
msg.SetSilent(true)
}
if m.NoForwards {
msg.SetNoforwards(true)
}
if m.Pinned {
msg.SetPinned(true)
}
if reply := tgMessageReplyHeader(m); reply != nil {
msg.SetReplyTo(reply)
}
if fwd := tgMessageFwdHeader(m.Forward); fwd != nil {
msg.SetFwdFrom(*fwd)
}
// self-chat 消息恒带 saved_peer_id收藏夹子会话分组键TDesktop 不做
// 本地 fwd 推导,缺失会把转发消息错归 My Notes。
if m.SavedPeer.ID != 0 {
if saved := tgPeer(m.SavedPeer); saved != nil {
msg.SetSavedPeerID(saved)
}
}
if from := tgPeer(m.From); from != nil {
msg.FromID = from
}
if m.ViaBotID != 0 {
msg.SetViaBotID(m.ViaBotID)
}
if m.GroupedID != 0 {
msg.SetGroupedID(m.GroupedID)
}
// 消息特效(私聊专属):非零则下发,客户端据此播放一次动画。
if m.Effect != 0 {
msg.SetEffect(m.Effect)
}
if !m.Media.IsZero() {
msg.SetMedia(tgMessageMedia(m.Media))
// invert_media 是 message 级标志,但存于媒体快照(免消息表列):仅当有媒体时投影。
if m.Media.InvertMedia {
msg.SetInvertMedia(true)
}
}
// reply_markupbot inline keyboard仅普通 tg.Message 携带service 消息不带)。
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
// rich_messageLayer 227 富文本消息best-effort 投影blocks 解码失败则略过
// tgMessage 无 error 返回corrupt blob 不应拖垮整条消息投影)。
if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil {
msg.SetRichMessage(*rich)
}
if m.TTLPeriod > 0 {
msg.SetTTLPeriod(m.TTLPeriod)
}
if reactions := tgMessageReactions(m.OwnerUserID, m.Reactions); reactions != nil {
msg.SetReactions(*reactions)
}
return msg
}
func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
m := msg.Media
if m == nil || m.Kind != domain.MessageMediaKindService || m.ServiceAction == nil {
return nil
}
switch m.ServiceAction.Kind {
case domain.MessageServiceActionSuggestProfilePhoto:
if m.ServiceAction.Photo == nil || m.ServiceAction.Photo.ID == 0 {
return &tg.MessageActionEmpty{}
}
return &tg.MessageActionSuggestProfilePhoto{Photo: tgPhoto(*m.ServiceAction.Photo)}
case domain.MessageServiceActionPinMessage:
return &tg.MessageActionPinMessage{}
case domain.MessageServiceActionSetChatTheme:
return &tg.MessageActionSetChatTheme{
Theme: &tg.ChatTheme{Emoticon: m.ServiceAction.ChatThemeEmoticon},
}
case domain.MessageServiceActionPhoneCall:
if m.ServiceAction.Call == nil {
return &tg.MessageActionEmpty{}
}
action := &tg.MessageActionPhoneCall{
Video: m.ServiceAction.Call.Video,
CallID: m.ServiceAction.Call.CallID,
}
if reason := tgPhoneCallDiscardReason(domain.PhoneCallDiscardReason(m.ServiceAction.Call.Reason)); reason != nil {
action.SetReason(reason)
}
if m.ServiceAction.Call.Duration > 0 {
action.SetDuration(m.ServiceAction.Call.Duration)
}
return action
case domain.MessageServiceActionBotAllowed:
allowed := m.ServiceAction.BotAllowed
if allowed == nil {
return &tg.MessageActionBotAllowed{}
}
return &tg.MessageActionBotAllowed{
AttachMenu: allowed.AttachMenu,
FromRequest: allowed.FromRequest,
Domain: allowed.Domain,
}
case domain.MessageServiceActionWebViewDataSent:
data := m.ServiceAction.WebViewData
if data == nil {
return &tg.MessageActionEmpty{}
}
if msg.Out {
return &tg.MessageActionWebViewDataSent{Text: data.ButtonText}
}
return &tg.MessageActionWebViewDataSentMe{
Text: data.ButtonText,
Data: data.Data,
}
case domain.MessageServiceActionRequestedPeer:
shared := m.ServiceAction.RequestedPeer
if shared == nil {
return &tg.MessageActionEmpty{}
}
if msg.Out {
return &tg.MessageActionRequestedPeerSentMe{
ButtonID: shared.ButtonID,
Peers: tgRequestedPeers(shared.Peers),
}
}
return &tg.MessageActionRequestedPeer{
ButtonID: shared.ButtonID,
Peers: tgPeerList(shared.Peers),
}
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
default:
return &tg.MessageActionEmpty{}
}
}
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
out := make([]tg.PeerClass, 0, len(peers))
for _, peer := range peers {
if converted := tgPeer(peer); converted != nil {
out = append(out, converted)
}
}
return out
}
func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass {
out := make([]tg.RequestedPeerClass, 0, len(peers))
for _, peer := range peers {
switch peer.Type {
case domain.PeerTypeUser:
out = append(out, &tg.RequestedPeerUser{UserID: peer.ID})
case domain.PeerTypeChannel:
out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID})
}
}
return out
}
func tgMessagesDiscussionMessage(viewerUserID int64, in domain.ChannelDiscussionMessage) *tg.MessagesDiscussionMessage {
messages := make([]tg.MessageClass, 0, len(in.Messages))
for _, msg := range in.Messages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
messages = append(messages, item)
}
}
channels := make([]domain.Channel, 0, 2+len(in.Channels))
if in.PostChannel.ID != 0 {
channels = append(channels, in.PostChannel)
}
if in.DiscussionChannel.ID != 0 && in.DiscussionChannel.ID != in.PostChannel.ID {
channels = append(channels, in.DiscussionChannel)
}
channels = append(channels, in.Channels...)
out := &tg.MessagesDiscussionMessage{
Messages: messages,
Chats: tgChannels(viewerUserID, channels),
Users: tgUsers(in.Users),
UnreadCount: in.UnreadCount,
}
if in.MaxID > 0 {
out.SetMaxID(in.MaxID)
}
if in.ReadInboxMaxID > 0 {
out.SetReadInboxMaxID(in.ReadInboxMaxID)
}
if in.ReadOutboxMaxID > 0 {
out.SetReadOutboxMaxID(in.ReadOutboxMaxID)
}
return out
}
func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
if m.ReplyTo == nil {
return nil
}
// story 回复(评论)投影为独立的 messageReplyStoryHeaderpeer=story 作者 + story_id
if m.ReplyTo.StoryID > 0 {
peer := tgPeer(m.ReplyTo.Peer)
if peer == nil {
return nil
}
return &tg.MessageReplyStoryHeader{Peer: peer, StoryID: m.ReplyTo.StoryID}
}
if m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0 {
return nil
}
header := &tg.MessageReplyHeader{}
if m.ReplyTo.ForumTopic {
header.SetForumTopic(true)
}
if m.ReplyTo.MessageID > 0 {
header.SetReplyToMsgID(m.ReplyTo.MessageID)
}
if m.ReplyTo.TopMessageID > 0 {
header.SetReplyToTopID(m.ReplyTo.TopMessageID)
}
if m.ReplyTo.Peer.ID != 0 && m.ReplyTo.Peer != m.Peer {
if peer := tgPeer(m.ReplyTo.Peer); peer != nil {
header.SetReplyToPeerID(peer)
}
}
if m.ReplyTo.QuoteText != "" {
header.SetQuote(true)
header.SetQuoteText(m.ReplyTo.QuoteText)
header.SetQuoteEntities(tgMessageEntities(m.ReplyTo.QuoteEntities))
header.SetQuoteOffset(m.ReplyTo.QuoteOffset)
}
return header
}
func tgMessageFwdHeader(fwd *domain.MessageForward) *tg.MessageFwdHeader {
if fwd == nil || (fwd.Date == 0 && fwd.From.ID == 0 && fwd.FromName == "" && fwd.ChannelPost == 0 && fwd.SavedFrom.ID == 0 && fwd.SavedFromMsgID == 0) {
return nil
}
header := &tg.MessageFwdHeader{Date: fwd.Date}
if peer := tgPeer(fwd.From); peer != nil {
header.SetFromID(peer)
}
if fwd.FromName != "" {
header.SetFromName(fwd.FromName)
}
if fwd.ChannelPost > 0 {
header.SetChannelPost(fwd.ChannelPost)
}
if peer := tgPeer(fwd.SavedFrom); peer != nil {
header.SetSavedFromPeer(peer)
}
if fwd.SavedFromMsgID > 0 {
header.SetSavedFromMsgID(fwd.SavedFromMsgID)
}
return header
}
func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions) *tg.MessageReactions {
if in == nil {
return nil
}
out := &tg.MessageReactions{
Results: make([]tg.ReactionCount, 0, len(in.Results)),
}
if in.CanSeeList {
out.SetCanSeeList(true)
}
for _, item := range in.Results {
reaction := tgMessageReaction(item.Reaction)
if reaction == nil || item.Count <= 0 {
continue
}
count := tg.ReactionCount{
Reaction: reaction,
Count: item.Count,
}
if item.ChosenOrder > 0 {
count.SetChosenOrder(item.ChosenOrder)
}
out.Results = append(out.Results, count)
}
if len(in.Recent) > 0 {
recent := make([]tg.MessagePeerReaction, 0, len(in.Recent))
for _, item := range in.Recent {
if converted := tgMessagePeerReaction(viewerUserID, item); converted != nil {
recent = append(recent, *converted)
}
}
if len(recent) > 0 {
out.SetRecentReactions(recent)
}
}
// 付费 reaction注入 ReactionPaid 计数 + top reactorsMy/chosen 由 in.Paid 的视角数据驱动,
// 调用方对他人视角已抹除 My/MyStars。统一在此注入覆盖所有频道消息读路径。
if in.Paid != nil {
injectPaidReaction(out, *in.Paid)
}
if out.Results == nil {
out.Results = []tg.ReactionCount{}
}
return out
}
func tgMessagePeerReaction(viewerUserID int64, in domain.ChannelMessagePeerReaction) *tg.MessagePeerReaction {
reaction := tgMessageReaction(in.Reaction)
if reaction == nil || in.UserID == 0 {
return nil
}
out := &tg.MessagePeerReaction{
PeerID: &tg.PeerUser{UserID: in.UserID},
Date: in.Date,
Reaction: reaction,
}
if in.Big {
out.SetBig(true)
}
if in.Unread && in.SenderUserID == viewerUserID {
out.SetUnread(true)
}
if in.My || in.UserID == viewerUserID {
out.SetMy(true)
}
return out
}
func tgMessageReaction(in domain.MessageReaction) tg.ReactionClass {
switch in.Type {
case domain.MessageReactionEmoji:
if in.Emoticon == "" {
return nil
}
return &tg.ReactionEmoji{Emoticon: in.Emoticon}
case domain.MessageReactionCustomEmoji:
if in.DocumentID <= 0 {
return nil
}
return &tg.ReactionCustomEmoji{DocumentID: in.DocumentID}
default:
return nil
}
}
func tgMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass {
if len(entities) == 0 {
return nil
}
out := make([]tg.MessageEntityClass, 0, len(entities))
for _, entity := range entities {
switch entity.Type {
case domain.MessageEntityBold:
out = append(out, &tg.MessageEntityBold{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityItalic:
out = append(out, &tg.MessageEntityItalic{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityUnderline:
out = append(out, &tg.MessageEntityUnderline{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityStrike:
out = append(out, &tg.MessageEntityStrike{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityCode:
out = append(out, &tg.MessageEntityCode{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityPre:
out = append(out, &tg.MessageEntityPre{Offset: entity.Offset, Length: entity.Length, Language: entity.Language})
case domain.MessageEntityTextURL:
out = append(out, &tg.MessageEntityTextURL{Offset: entity.Offset, Length: entity.Length, URL: entity.URL})
case domain.MessageEntityMentionName:
out = append(out, &tg.MessageEntityMentionName{Offset: entity.Offset, Length: entity.Length, UserID: entity.UserID})
case domain.MessageEntitySpoiler:
out = append(out, &tg.MessageEntitySpoiler{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityBlockquote:
out = append(out, &tg.MessageEntityBlockquote{Offset: entity.Offset, Length: entity.Length, Collapsed: entity.Collapsed})
case domain.MessageEntityCustomEmoji:
out = append(out, &tg.MessageEntityCustomEmoji{Offset: entity.Offset, Length: entity.Length, DocumentID: entity.DocumentID})
case domain.MessageEntityMention:
out = append(out, &tg.MessageEntityMention{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityHashtag:
out = append(out, &tg.MessageEntityHashtag{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityCashtag:
out = append(out, &tg.MessageEntityCashtag{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityBotCommand:
out = append(out, &tg.MessageEntityBotCommand{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityURL:
out = append(out, &tg.MessageEntityURL{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityEmail:
out = append(out, &tg.MessageEntityEmail{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityPhone:
out = append(out, &tg.MessageEntityPhone{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityBankCard:
out = append(out, &tg.MessageEntityBankCard{Offset: entity.Offset, Length: entity.Length})
}
}
return out
}
func domainMessageEntities(entities []tg.MessageEntityClass) []domain.MessageEntity {
return domainMessageEntitiesForViewer(0, entities)
}
// domainMessageEntitiesForViewer 把客户端实体转成 domain 形态并原样保留语义
// 字段inputMessageEntityMentionName 携带 InputUserinputUserSelf 解析为
// viewerUserIDviewer 未知时该实体丢弃而不是落成 user_id=0
func domainMessageEntitiesForViewer(viewerUserID int64, entities []tg.MessageEntityClass) []domain.MessageEntity {
if len(entities) == 0 {
return nil
}
out := make([]domain.MessageEntity, 0, len(entities))
for _, entity := range entities {
switch e := entity.(type) {
case *tg.MessageEntityBold:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityBold, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityItalic:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityItalic, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityUnderline:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityUnderline, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityStrike:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityStrike, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityCode:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityCode, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityPre:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityPre, Offset: e.Offset, Length: e.Length, Language: e.Language})
case *tg.MessageEntityTextURL:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityTextURL, Offset: e.Offset, Length: e.Length, URL: e.URL})
case *tg.MessageEntityMentionName:
if e.UserID != 0 {
out = append(out, domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: e.Offset, Length: e.Length, UserID: e.UserID})
}
case *tg.InputMessageEntityMentionName:
userID := int64(0)
switch input := e.UserID.(type) {
case *tg.InputUser:
userID = input.UserID
case *tg.InputUserSelf:
userID = viewerUserID
}
if userID != 0 {
out = append(out, domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: e.Offset, Length: e.Length, UserID: userID})
}
case *tg.MessageEntitySpoiler:
out = append(out, domain.MessageEntity{Type: domain.MessageEntitySpoiler, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityBlockquote:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: e.Offset, Length: e.Length, Collapsed: e.Collapsed})
case *tg.MessageEntityCustomEmoji:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityCustomEmoji, Offset: e.Offset, Length: e.Length, DocumentID: e.DocumentID})
case *tg.MessageEntityMention:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityMention, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityHashtag:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityHashtag, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityCashtag:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityCashtag, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityBotCommand:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityBotCommand, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityURL:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityURL, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityEmail:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityEmail, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityPhone:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityPhone, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityBankCard:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityBankCard, Offset: e.Offset, Length: e.Length})
}
}
return out
}
func tgPeer(p domain.Peer) tg.PeerClass {
switch p.Type {
case domain.PeerTypeUser:
return &tg.PeerUser{UserID: p.ID}
case domain.PeerTypeChannel:
return &tg.PeerChannel{ChannelID: p.ID}
default:
return nil
}
}
func tgMigratedLegacyChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Chat {
if ch.ID == 0 || ch.Deleted {
return nil
}
out := &tg.Chat{
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
Deactivated: true,
Noforwards: ch.NoForwards,
ID: ch.ID,
Title: ch.Title,
Photo: tgChatPhoto(ch),
ParticipantsCount: ch.ParticipantsCount,
Date: ch.Date,
Version: ch.Pts,
}
out.SetMigratedTo(&tg.InputChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash})
if self != nil {
if self.Status == domain.ChannelMemberLeft {
out.Left = true
}
switch self.Role {
case domain.ChannelRoleCreator:
out.Creator = true
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
case domain.ChannelRoleAdmin:
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
}
}
out.SetDefaultBannedRights(tgDefaultChatBannedRights(ch.DefaultBannedRights))
return out
}

View file

@ -0,0 +1,195 @@
package rpc
import (
"encoding/json"
"github.com/gotd/td/tg"
"sort"
"telesrv/internal/domain"
)
func tgLangPackStrings(items []domain.LangPackString) []tg.LangPackStringClass {
out := make([]tg.LangPackStringClass, 0, len(items))
for _, item := range items {
if item.Deleted {
out = append(out, &tg.LangPackStringDeleted{Key: item.Key})
continue
}
if item.Pluralized {
out = append(out, &tg.LangPackStringPluralized{
Key: item.Key,
ZeroValue: item.ZeroValue,
OneValue: item.OneValue,
TwoValue: item.TwoValue,
FewValue: item.FewValue,
ManyValue: item.ManyValue,
OtherValue: item.OtherValue,
})
continue
}
out = append(out, &tg.LangPackString{Key: item.Key, Value: item.Value})
}
return out
}
func tgPassword(settings domain.PasswordSettings) *tg.AccountPassword {
if len(settings.SecureRandom) == 0 {
settings.SecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
}
out := &tg.AccountPassword{
HasRecovery: settings.HasRecovery,
HasSecureValues: settings.HasSecureValues,
HasPassword: settings.HasPassword,
Hint: settings.Hint,
EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern,
NewAlgo: tgPasswordAlgo(settings.NewAlgo),
NewSecureAlgo: tgSecurePasswordAlgo(settings.NewSecureAlgo),
SecureRandom: settings.SecureRandom,
LoginEmailPattern: settings.LoginEmailPattern,
}
if settings.HasPassword && settings.CurrentAlgo != nil {
out.CurrentAlgo = tgPasswordAlgo(*settings.CurrentAlgo)
out.SRPB = append([]byte(nil), settings.SRPB...)
out.SRPID = settings.SRPID
}
if settings.PendingResetDate != 0 {
out.PendingResetDate = settings.PendingResetDate
}
return out
}
func tgPasswordAlgo(algo domain.PasswordKDFAlgo) tg.PasswordKdfAlgoClass {
if len(algo.P) == 0 || algo.G == 0 {
return &tg.PasswordKdfAlgoUnknown{}
}
return &tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow{
Salt1: append([]byte(nil), algo.Salt1...),
Salt2: append([]byte(nil), algo.Salt2...),
G: algo.G,
P: append([]byte(nil), algo.P...),
}
}
func domainPasswordAlgo(in tg.PasswordKdfAlgoClass) (*domain.PasswordKDFAlgo, bool) {
if algo, ok := in.(*tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow); ok {
return &domain.PasswordKDFAlgo{
Salt1: append([]byte(nil), algo.Salt1...),
Salt2: append([]byte(nil), algo.Salt2...),
G: algo.G,
P: append([]byte(nil), algo.P...),
}, true
}
return nil, false
}
func tgSecurePasswordAlgo(algo domain.SecurePasswordKDFAlgo) tg.SecurePasswordKdfAlgoClass {
if algo.Kind == "pbkdf2_hmac_sha512_iter100000" {
return &tg.SecurePasswordKdfAlgoPBKDF2HMACSHA512iter100000{Salt: append([]byte(nil), algo.Salt...)}
}
return &tg.SecurePasswordKdfAlgoUnknown{}
}
func domainPasswordCheck(in tg.InputCheckPasswordSRPClass) domain.PasswordCheck {
if srp, ok := in.(*tg.InputCheckPasswordSRP); ok {
return domain.PasswordCheck{
SRPID: srp.SRPID,
A: append([]byte(nil), srp.A...),
M1: append([]byte(nil), srp.M1...),
}
}
return domain.PasswordCheck{Empty: true}
}
func domainPasswordInputSettings(in tg.AccountPasswordInputSettings) (domain.PasswordInputSettings, error) {
out := domain.PasswordInputSettings{}
if algo, ok := in.GetNewAlgo(); ok {
domainAlgo, ok := domainPasswordAlgo(algo)
if !ok {
return out, passwordHashInvalidErr()
}
out.NewAlgo = domainAlgo
out.NewPasswordHash = append([]byte(nil), in.NewPasswordHash...)
out.Hint = in.Hint
out.HasHint = true
}
if email, ok := in.GetEmail(); ok {
out.Email = email
out.HasEmail = true
}
return out, nil
}
func tgPasswordSettings(settings domain.PrivatePasswordSettings) *tg.AccountPasswordSettings {
out := &tg.AccountPasswordSettings{}
if settings.Email != "" {
out.Email = settings.Email
}
return out
}
func tgCountriesList(list domain.CountriesList) tg.HelpCountriesListClass {
out := &tg.HelpCountriesList{
Hash: list.Hash,
Countries: make([]tg.HelpCountry, 0, len(list.Countries)),
}
for _, country := range list.Countries {
item := tg.HelpCountry{
Hidden: country.Hidden,
ISO2: country.ISO2,
DefaultName: country.DefaultName,
Name: country.Name,
CountryCodes: make([]tg.HelpCountryCode, 0, len(country.CountryCodes)),
}
for _, code := range country.CountryCodes {
item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{
CountryCode: code.CountryCode,
Prefixes: code.Prefixes,
Patterns: code.Patterns,
})
}
out.Countries = append(out.Countries, item)
}
return out
}
func tgJSONValue(data []byte) tg.JSONValueClass {
if len(data) == 0 {
return &tg.JSONObject{}
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
return &tg.JSONObject{}
}
return tgJSON(v)
}
func tgJSON(v any) tg.JSONValueClass {
switch x := v.(type) {
case nil:
return &tg.JSONNull{}
case bool:
return &tg.JSONBool{Value: x}
case float64:
return &tg.JSONNumber{Value: x}
case string:
return &tg.JSONString{Value: x}
case []any:
arr := &tg.JSONArray{Value: make([]tg.JSONValueClass, 0, len(x))}
for _, item := range x {
arr.Value = append(arr.Value, tgJSON(item))
}
return arr
case map[string]any:
obj := &tg.JSONObject{Value: make([]tg.JSONObjectValue, 0, len(x))}
keys := make([]string, 0, len(x))
for key := range x {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
obj.Value = append(obj.Value, tg.JSONObjectValue{Key: key, Value: tgJSON(x[key])})
}
return obj
default:
return &tg.JSONString{Value: ""}
}
}

View file

@ -0,0 +1,82 @@
package rpc
import (
"encoding/base64"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// decodePasskeyID 容忍有/无填充地 base64url 解码 credential id(取第一个可解的非空值)。
func decodePasskeyID(vals ...string) ([]byte, bool) {
for _, v := range vals {
if v == "" {
continue
}
if b, err := base64.RawURLEncoding.DecodeString(v); err == nil && len(b) > 0 {
return b, true
}
if b, err := base64.URLEncoding.DecodeString(v); err == nil && len(b) > 0 {
return b, true
}
}
return nil, false
}
func passkeyPublicKeyCredential(cred tg.InputPasskeyCredentialClass) (*tg.InputPasskeyCredentialPublicKey, bool) {
pk, ok := cred.(*tg.InputPasskeyCredentialPublicKey)
return pk, ok
}
// passkeyLoginFromCredential 从 inputPasskeyCredentialPublicKey 提取登录断言字段。
func passkeyLoginFromCredential(cred tg.InputPasskeyCredentialClass) (credID []byte, login *tg.InputPasskeyResponseLogin, ok bool) {
pk, ok := passkeyPublicKeyCredential(cred)
if !ok {
return nil, nil, false
}
login, ok = pk.Response.(*tg.InputPasskeyResponseLogin)
if !ok {
return nil, nil, false
}
credID, ok = decodePasskeyID(pk.RawID, pk.ID)
if !ok {
return nil, nil, false
}
return credID, login, true
}
// passkeyRegisterFromCredential 从 inputPasskeyCredentialPublicKey 提取注册 attestation 字段。
func passkeyRegisterFromCredential(cred tg.InputPasskeyCredentialClass) (credID []byte, reg *tg.InputPasskeyResponseRegister, ok bool) {
pk, ok := passkeyPublicKeyCredential(cred)
if !ok {
return nil, nil, false
}
reg, ok = pk.Response.(*tg.InputPasskeyResponseRegister)
if !ok {
return nil, nil, false
}
credID, ok = decodePasskeyID(pk.RawID, pk.ID)
if !ok {
return nil, nil, false
}
return credID, reg, true
}
// tgPasskey 把领域凭据投影为 tg.Passkey(ID 用 base64url)。
func tgPasskey(c domain.PasskeyCredential) tg.Passkey {
return tg.Passkey{
ID: base64.RawURLEncoding.EncodeToString(c.CredentialID),
Name: c.Name,
Date: int(c.CreatedAt),
LastUsageDate: int(c.LastUsedAt),
}
}
func tgPasskeys(creds []domain.PasskeyCredential) []tg.Passkey {
out := make([]tg.Passkey, 0, len(creds))
for _, c := range creds {
out = append(out, tgPasskey(c))
}
return out
}

View file

@ -0,0 +1,190 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// tgPhoneCallProtocol 把协商参数转回 TL。
func tgPhoneCallProtocol(p domain.PhoneCallProtocol) tg.PhoneCallProtocol {
return tg.PhoneCallProtocol{
UDPP2P: p.UDPP2P,
UDPReflector: p.UDPReflector,
MinLayer: p.MinLayer,
MaxLayer: p.MaxLayer,
LibraryVersions: p.LibraryVersions,
}
}
func phoneCallProtocolFromTL(p tg.PhoneCallProtocol) domain.PhoneCallProtocol {
return domain.PhoneCallProtocol{
UDPP2P: p.UDPP2P,
UDPReflector: p.UDPReflector,
MinLayer: p.MinLayer,
MaxLayer: p.MaxLayer,
LibraryVersions: p.LibraryVersions,
}
}
// tgPhoneCallForViewer 按观察者视角把服务端状态映射为 TL phoneCall* 构造器。
// 这是客户端硬契约access_hash / admin_id / participant_id 全生命周期一致;
// Confirmed 态主叫看到的 g_a_or_b 是对方的 g_b、被叫看到的是 g_a。
func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallClass {
switch call.State {
case domain.PhoneCallStateRequested, domain.PhoneCallStateRinging:
if viewerID == call.ParticipantID {
return &tg.PhoneCallRequested{
Video: call.Video,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
AdminID: call.AdminID,
ParticipantID: call.ParticipantID,
GAHash: call.GAHash,
Protocol: tgPhoneCallProtocol(call.CallerProtocol),
}
}
return phoneCallWaitingView(call)
case domain.PhoneCallStateAccepted:
if viewerID == call.AdminID {
return &tg.PhoneCallAccepted{
Video: call.Video,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
AdminID: call.AdminID,
ParticipantID: call.ParticipantID,
GB: call.GB,
Protocol: tgPhoneCallProtocol(call.Protocol),
}
}
return phoneCallWaitingView(call)
case domain.PhoneCallStateConfirmed:
gaOrB := call.GA // 被叫视角:拿主叫揭示的 g_a 验承诺、算 AuthKey
if viewerID == call.AdminID {
gaOrB = call.GB // 主叫视角:拿被叫的 g_b
}
out := &tg.PhoneCall{
P2PAllowed: call.P2PAllowed,
Video: call.Video,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
AdminID: call.AdminID,
ParticipantID: call.ParticipantID,
GAOrB: gaOrB,
KeyFingerprint: call.KeyFingerprint,
Protocol: tgPhoneCallProtocol(call.Protocol),
Connections: tgPhoneConnections(call.Connections),
StartDate: call.StartDate,
}
return out
case domain.PhoneCallStateDiscarded:
return tgPhoneCallDiscarded(call)
default:
return &tg.PhoneCallEmpty{ID: call.ID}
}
}
// tgPhoneConnections 把签发的 STUN/TURN 条目转为 TL phoneConnectionWebrtc。
// 永远返回非 nilgotd 对 nil vector 编码与空 vector 相同,但保持显式)。
// 不下发 legacy phoneConnectionTelegram 自有 reflector 协议):现代 tgcalls
// 走 WebRTC ICE只消费 webrtc 条目。
func tgPhoneConnections(conns []domain.PhoneCallConnection) []tg.PhoneConnectionClass {
out := make([]tg.PhoneConnectionClass, 0, len(conns))
for _, c := range conns {
out = append(out, &tg.PhoneConnectionWebrtc{
Turn: c.Turn,
Stun: c.Stun,
ID: c.ID,
IP: c.IP,
Ipv6: "",
Port: c.Port,
Username: c.Username,
Password: c.Password,
})
}
return out
}
func phoneCallWaitingView(call domain.PhoneCall) *tg.PhoneCallWaiting {
out := &tg.PhoneCallWaiting{
Video: call.Video,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
AdminID: call.AdminID,
ParticipantID: call.ParticipantID,
Protocol: tgPhoneCallProtocol(call.Protocol),
}
if call.ReceiveDate > 0 {
// 主叫据 receive_date != 0 从「等待」切「对方振铃中」并换用 90s ring 定时器。
out.SetReceiveDate(call.ReceiveDate)
}
return out
}
// tgPhoneCallDiscarded 构造终态视图。need_rating/need_debug 恒不置位:
// 客户端便不弹评分框、不上传 tgcalls debug 数据。
func tgPhoneCallDiscarded(call domain.PhoneCall) *tg.PhoneCallDiscarded {
out := &tg.PhoneCallDiscarded{
Video: call.Video,
ID: call.ID,
}
if reason := tgPhoneCallDiscardReason(call.DiscardReason); reason != nil {
out.SetReason(reason)
}
if call.Duration > 0 {
out.SetDuration(call.Duration)
}
return out
}
// tgPhoneCallStopRinging 构造「被叫其它设备停振铃」的合成终态。
//
// 硬契约(勿改回推 phoneCallAcceptedTDesktop incoming 侧收到 phoneCallAccepted
// 会 finish(Failed) 并回发 discardCall、DrKLO 会按主叫流程发 confirmCall——任一都会
// 杀死刚建立的通话。无 busy/migrate reason 的 phoneCallDiscarded 才会让两端走
// EndedByOtherDevice / 停止前台服务且不回发 RPC。本构造不改服务端状态、不进 tombstone。
func tgPhoneCallStopRinging(call domain.PhoneCall) *tg.PhoneCallDiscarded {
return &tg.PhoneCallDiscarded{
Video: call.Video,
ID: call.ID,
}
}
func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass {
switch r {
case domain.PhoneCallDiscardReasonMissed:
return &tg.PhoneCallDiscardReasonMissed{}
case domain.PhoneCallDiscardReasonDisconnect:
return &tg.PhoneCallDiscardReasonDisconnect{}
case domain.PhoneCallDiscardReasonHangup:
return &tg.PhoneCallDiscardReasonHangup{}
case domain.PhoneCallDiscardReasonBusy:
return &tg.PhoneCallDiscardReasonBusy{}
case domain.PhoneCallDiscardReasonMigrateConference:
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{}
default:
return nil
}
}
func phoneCallDiscardReasonFromTL(r tg.PhoneCallDiscardReasonClass) domain.PhoneCallDiscardReason {
switch r.(type) {
case *tg.PhoneCallDiscardReasonMissed:
return domain.PhoneCallDiscardReasonMissed
case *tg.PhoneCallDiscardReasonDisconnect:
return domain.PhoneCallDiscardReasonDisconnect
case *tg.PhoneCallDiscardReasonBusy:
return domain.PhoneCallDiscardReasonBusy
case *tg.PhoneCallDiscardReasonMigrateConferenceCall:
return domain.PhoneCallDiscardReasonMigrateConference
case *tg.PhoneCallDiscardReasonHangup:
return domain.PhoneCallDiscardReasonHangup
default:
// 含 nil客户端未带 reason按 hangup 处理。
return domain.PhoneCallDiscardReasonHangup
}
}

View file

@ -0,0 +1,339 @@
package rpc
import (
"encoding/json"
"fmt"
"strconv"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
"telesrv/internal/sfu"
)
// ---- TL 转换 ----
// groupCallUnmutedVideoLimit 是同时开视频的参与者上限(官方经 appConfig
// groupcall_video_participants_max 下发,缺省 30
// ⚠ unmuted_video_limit 是 TL 非可选 int留 0 会让两端的视频/屏幕共享闸门
// `activeVideoSendersCount() >= limit` 恒真——「You can't share your screen in
// this chat」即此TDesktop emitShareScreenError / DrKLO ChatObject.canStreamVideo
const groupCallUnmutedVideoLimit = 30
// tgGroupCall 把 call 行转为 TL groupCall。
func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.GroupCallClass {
if !call.Active() {
return &tg.GroupCallDiscarded{ID: call.ID, AccessHash: call.AccessHash, Duration: call.Duration}
}
out := &tg.GroupCall{
JoinMuted: call.JoinMuted,
CanChangeJoinMuted: canManage,
Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0,
// can_start_videoTDesktop 不读DrKLO 用它喂入会前 dummy self 行的
// video_joined。RTC 通话一律放行。
CanStartVideo: true,
ID: call.ID,
AccessHash: call.AccessHash,
ParticipantsCount: call.ParticipantsCount,
UnmutedVideoLimit: groupCallUnmutedVideoLimit,
Version: call.Version,
}
if call.Title != "" {
out.SetTitle(call.Title)
}
return out
}
// tgGroupCallParticipant 按 viewer 视角转换参与者行Self flag per-viewer
func tgGroupCallParticipant(p domain.GroupCallParticipant, viewerUserID int64) tg.GroupCallParticipant {
out := tg.GroupCallParticipant{
Muted: p.Muted,
Left: p.Left,
CanSelfUnmute: !p.MutedByAdmin,
Self: p.UserID == viewerUserID,
Peer: &tg.PeerUser{UserID: p.UserID},
Date: p.JoinDate,
Source: int(int32(uint32(p.SSRC))), // uint32 按位转 int32join JSON 同款语义)
}
if p.Left {
// left 行不再携带 can_self_unmute 语义。
out.CanSelfUnmute = false
} else {
// video_joinedRTC 入会一律置位。self 行缺它会让 TDesktop 置
// _videoIsWorking=false 并强制关掉本端摄像头/屏幕共享。
out.VideoJoined = true
}
if p.ActiveDate > 0 {
out.SetActiveDate(p.ActiveDate)
}
if p.VolumeByAdmin > 0 {
out.VolumeByAdmin = true
out.SetVolume(p.VolumeByAdmin)
}
if p.RaiseHandRating > 0 {
out.SetRaiseHandRating(p.RaiseHandRating)
}
// 视频/屏幕共享Active 且未离会才输出字段video_stopped ⇒ 字段消失;
// paused ⇒ 字段保留但置 paused flag。min 行overrides 推送)不会走到
// 这里改写 videoParams——applyParticipantOverride 只动 muted/volume。
if !p.Left {
if st, ok := decodeVideoState(p.VideoJSON); ok && st.Active {
out.SetVideo(*tgParticipantVideo(st, false))
}
if st, ok := decodeVideoState(p.PresentationJSON); ok && st.Active {
out.SetPresentation(*tgParticipantVideo(st, true))
}
}
return out
}
func tgGroupCallParticipants(rows []domain.GroupCallParticipant, viewerUserID int64) []tg.GroupCallParticipant {
out := make([]tg.GroupCallParticipant, 0, len(rows))
for _, p := range rows {
out = append(out, tgGroupCallParticipant(p, viewerUserID))
}
return out
}
// applyParticipantOverride 把 setter 的 per-viewer 覆盖(本地静音/音量)叠加到推给
// 该 setter 的 participant 行上,并置 min flag——min 告诉客户端「本行的部分字段
// 不可全信,按本地状态保留」,防止 setter 其它设备用此行覆盖本地 muted/volume。
func applyParticipantOverride(p tg.GroupCallParticipant, ov domain.GroupCallParticipantOverride) tg.GroupCallParticipant {
p.SetMin(true)
if ov.MutedByYou {
p.SetMutedByYou(true)
}
if ov.Volume > 0 {
p.SetVolume(ov.Volume)
}
return p
}
// inputGroupCallRef 解析 InputGroupCallClass 的三个变体。
// slug / inviteMessage 变体属 conference 路径(范围外),返回 GROUPCALL_INVALID
// 而非类型断言 panic——TDesktop conference 迁移会真实发出 inputGroupCallSlug。
func inputGroupCallRef(call tg.InputGroupCallClass) (callID, accessHash int64, err error) {
switch v := call.(type) {
case *tg.InputGroupCall:
return v.ID, v.AccessHash, nil
default:
return 0, 0, groupCallInvalidErr()
}
}
// ---- join JSON上行----
// groupCallJoinPayload 是 phone.joinGroupCall.params 的上行 JSON
// tgcalls GroupJoinPayloadInternal::serialize 的产物,逐字段对应)。
type groupCallJoinPayload struct {
SSRC int32 `json:"ssrc"`
Ufrag string `json:"ufrag"`
Pwd string `json:"pwd"`
Fingerprints []groupCallFingerprint `json:"fingerprints"`
SsrcGroups []groupCallSsrcGroup `json:"ssrc-groups,omitempty"`
}
type groupCallFingerprint struct {
Hash string `json:"hash"`
Fingerprint string `json:"fingerprint"`
Setup string `json:"setup"`
}
type groupCallSsrcGroup struct {
Semantics string `json:"semantics"`
Sources []int64 `json:"sources"`
}
// parseGroupCallJoinPayload 解析并校验上行 JSON解析必须容忍多余字段。
// ssrc-groups视频 simulcast/RTX 源组)无论摄像头开关都会随主 join 携带,
// 必须存档:等 video_stopped=false 时原样回放进 participant.video。
func parseGroupCallJoinPayload(data string) (sfu.ClientOffer, int64, error) {
var payload groupCallJoinPayload
if err := json.Unmarshal([]byte(data), &payload); err != nil {
return sfu.ClientOffer{}, 0, fmt.Errorf("parse join payload: %w", err)
}
if payload.Ufrag == "" || payload.Pwd == "" || payload.SSRC == 0 {
return sfu.ClientOffer{}, 0, fmt.Errorf("join payload missing ssrc/ufrag/pwd")
}
offer := sfu.ClientOffer{
// ssrc 是把 uint32 按位转 int32 的有符号值,还原为 uint32。
AudioSSRC: uint32(payload.SSRC),
Ufrag: payload.Ufrag,
Pwd: payload.Pwd,
}
for _, fp := range payload.Fingerprints {
if fp.Hash == "sha-256" {
offer.FingerprintSHA256 = fp.Fingerprint
break
}
}
if offer.FingerprintSHA256 == "" {
return sfu.ClientOffer{}, 0, fmt.Errorf("join payload missing sha-256 fingerprint")
}
for _, g := range payload.SsrcGroups {
sg := sfu.SsrcGroup{Semantics: g.Semantics, Sources: make([]uint32, 0, len(g.Sources))}
for _, src := range g.Sources {
// sources 同样是 int32 位重解释。
sg.Sources = append(sg.Sources, uint32(int32(src)))
}
offer.SsrcGroups = append(offer.SsrcGroups, sg)
}
return offer, int64(offer.AudioSSRC), nil
}
// ---- participant 视频内部状态video_json / presentation_json 列)----
// participantVideoState 是存进 video_json/presentation_json 的内部快照(非 wire
// 格式endpoint 由服务端铸造且必须与 join 响应 video.endpoint 逐字节一致;
// source_groups 是上行 ssrc-groups 的原样存档Active 决定 TL 字段是否出现。
type participantVideoState struct {
Endpoint string `json:"endpoint"`
SourceGroups []groupCallSsrcGroup `json:"source_groups,omitempty"`
Active bool `json:"active"`
Paused bool `json:"paused,omitempty"`
// AudioSource 仅 presentation屏幕实例自己的音频 ssrcint32 位重解释存原值)。
AudioSource int64 `json:"audio_source,omitempty"`
}
func encodeVideoState(st participantVideoState) []byte {
out, err := json.Marshal(st)
if err != nil {
return nil
}
return out
}
func decodeVideoState(raw []byte) (participantVideoState, bool) {
if len(raw) == 0 {
return participantVideoState{}, false
}
var st participantVideoState
if err := json.Unmarshal(raw, &st); err != nil || st.Endpoint == "" {
return participantVideoState{}, false
}
return st, true
}
// tgParticipantVideo 把内部状态转为 TL groupCallParticipantVideoActive 才输出)。
// withAudioSource 仅 presentation 置 trueDrKLO 用 audio_source 做屏幕 mySource
// 缺失会退化取首个视频 ssrc 致 checkGroupCall 死循环重建)。
func tgParticipantVideo(st participantVideoState, withAudioSource bool) *tg.GroupCallParticipantVideo {
out := &tg.GroupCallParticipantVideo{
Paused: st.Paused,
Endpoint: st.Endpoint,
}
for _, g := range st.SourceGroups {
sg := tg.GroupCallParticipantVideoSourceGroup{Semantics: g.Semantics}
for _, src := range g.Sources {
sg.Sources = append(sg.Sources, int(int32(uint32(src))))
}
out.SourceGroups = append(out.SourceGroups, sg)
}
if withAudioSource && st.AudioSource != 0 {
out.SetAudioSource(int(int32(uint32(st.AudioSource))))
}
return out
}
// groupCallSsrcGroupsFromOffer 把解析后的源组转回存档形态(保持 int32 位重解释
// 的有符号写法,与上行 JSON 一致)。
func groupCallSsrcGroupsFromOffer(offer sfu.ClientOffer) []groupCallSsrcGroup {
out := make([]groupCallSsrcGroup, 0, len(offer.SsrcGroups))
for _, g := range offer.SsrcGroups {
sg := groupCallSsrcGroup{Semantics: g.Semantics, Sources: make([]int64, 0, len(g.Sources))}
for _, src := range g.Sources {
sg.Sources = append(sg.Sources, int64(int32(src)))
}
out = append(out, sg)
}
return out
}
// groupCallEndpointID 铸造 camera/presentation 的 endpoint 串call 内唯一
// ssrc 有活跃唯一索引兜底)、同 participant 两路互异、rejoin 换 ssrc 自然换串。
func groupCallEndpointID(kind sfu.EndpointKind, audioSSRC uint32) string {
if kind == sfu.EndpointPresentation {
return fmt.Sprintf("presentation-%d", audioSSRC)
}
return fmt.Sprintf("audio-%d", audioSSRC)
}
// ---- 下行 JSONupdateGroupCallConnection.params----
// videoRtcpFbs 是视频 codec 的反馈能力表(与客户端本地 addDefaultFeedbackParams
// 一致goog-remb/transport-cc/ccm fir/nack/nack pli
func videoRtcpFbs() []map[string]any {
return []map[string]any{
{"type": "goog-remb"},
{"type": "transport-cc"},
{"type": "ccm", "subtype": "fir"},
{"type": "nack"},
{"type": "nack", "subtype": "pli"},
}
}
// buildGroupCallConnectionParams 组装下行 transport JSON契约 schema 逐字段照抄,
// 解析方为 tgcalls GroupJoinPayloadInternal/GroupNetworkManager。要点
// - transport.fingerprints.setup="active"SFU 是 DTLS 主动握手方;
// - candidates 全部字段都是字符串(数值也要写成字符串);
// - video 节即使纯收看也必须存在(缺失则客户端不建任何视频通道);
// - video.endpoint 是**本参与者自己**的视频 endpoint 标识,必须与日后写进其
// participant.video.endpoint 的串逐字节一致(客户端靠它做自我过滤);
// - payload-types 照抄客户端本地确定性分配VP8=100/rtx101、VP9=102/rtx103
// PT 由客户端自配,本表主要给其它平台客户端消费);不含 H264 ⇒ 全网 VP8 缺省;
// - rtp-hdrexts 照抄客户端硬编码 id 表1=audio-level、2=abs-send-time、
// 3=transport-wide-cc、13=video-orientation
func buildGroupCallConnectionParams(answer sfu.ServerAnswer, endpoint string) (string, error) {
candidates := make([]map[string]any, 0, len(answer.Candidates))
for i, c := range answer.Candidates {
candidates = append(candidates, map[string]any{
"component": "1",
"protocol": c.Protocol,
"port": strconv.Itoa(c.Port),
"ip": c.IP,
"type": c.Type,
"priority": "2130706431",
"foundation": "1",
"generation": "0",
"network": "1",
"id": fmt.Sprintf("c%d", i+1),
})
}
params := map[string]any{
"transport": map[string]any{
"ufrag": answer.Ufrag,
"pwd": answer.Pwd,
"fingerprints": []map[string]any{{
"hash": "sha-256",
"fingerprint": answer.FingerprintSHA256,
"setup": "active",
}},
"candidates": candidates,
},
"video": map[string]any{
"endpoint": endpoint,
"payload-types": []map[string]any{
{"id": 100, "name": "VP8", "clockrate": 90000, "channels": 0,
"parameters": map[string]any{}, "rtcp-fbs": videoRtcpFbs()},
{"id": 101, "name": "rtx", "clockrate": 90000, "channels": 0,
"parameters": map[string]any{"apt": "100"}, "rtcp-fbs": []map[string]any{}},
{"id": 102, "name": "VP9", "clockrate": 90000, "channels": 0,
"parameters": map[string]any{}, "rtcp-fbs": videoRtcpFbs()},
{"id": 103, "name": "rtx", "clockrate": 90000, "channels": 0,
"parameters": map[string]any{"apt": "102"}, "rtcp-fbs": []map[string]any{}},
},
"rtp-hdrexts": []map[string]any{
{"id": 1, "uri": "urn:ietf:params:rtp-hdrext:ssrc-audio-level"},
{"id": 2, "uri": "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"},
{"id": 3, "uri": "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"},
{"id": 13, "uri": "urn:3gpp:video-orientation"},
},
"server_sources": []int64{},
},
}
out, err := json.Marshal(params)
if err != nil {
return "", fmt.Errorf("marshal connection params: %w", err)
}
return string(out), nil
}

View file

@ -0,0 +1,336 @@
package rpc
import (
"context"
"strconv"
"strings"
"unicode/utf8"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// 本文件集中 poll 的 tg↔domain 转换与 updateMessagePoll 组装。
// 定义快照MessagePoll不含机密机密只进 PollDefinitionpolls 权威表)。
// resolvePollAuxMedia 解析 poll 配图(答案图/题干图):只允许引用或上传 photo/document
// 其余 InputMedia含嵌套 poll/todo——会先建孤儿权威行显式拒绝。
func (r *Router) resolvePollAuxMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) {
switch input.(type) {
case *tg.InputMediaPhoto, *tg.InputMediaDocument, *tg.InputMediaUploadedPhoto, *tg.InputMediaUploadedDocument:
default:
return nil, mediaInvalidErr()
}
media, err := r.resolveInputMedia(ctx, userID, input)
if err != nil {
return nil, err
}
if media == nil || (media.Kind != domain.MessageMediaKindPhoto && media.Kind != domain.MessageMediaKindDocument) {
return nil, mediaInvalidErr()
}
return media, nil
}
// domainPollFromInputMedia 校验 InputMediaPoll 并产出 (渲染快照, 权威定义)。
// pollID 由调用方分配now 用于 close_period→close_date 折算。
func (r *Router) domainPollFromInputMedia(ctx context.Context, in *tg.InputMediaPoll, creatorUserID, pollID int64, now int) (*domain.MessagePoll, domain.PollDefinition, error) {
var zero domain.PollDefinition
poll := in.Poll
if poll.SubscribersOnly || len(poll.CountriesISO2) > 0 {
// subscribers-only / 国家限定是新版商业能力,当前无业务模型。
return nil, zero, mediaInvalidErr()
}
// open_answers开放式答案允许他人添加选项在 TDesktop 创建面板默认勾选
//kDefaultPollCreateFlags不能拒其依赖的 addPollAnswer/deletePollAnswer
// 未接入,故静默剥离不回 echo——客户端不渲染"添加答案"入口,行为自洽(矩阵 todo
question := poll.Question.Text
if strings.TrimSpace(question) == "" {
return nil, zero, mediaEmptyErr()
}
if utf8.RuneCountInString(question) > domain.MaxPollQuestionLength {
return nil, zero, mediaInvalidErr()
}
if len(poll.Question.Entities) > maxMessageEntityCount {
return nil, zero, limitInvalidErr()
}
if len(poll.Answers) < domain.MinPollAnswers {
return nil, zero, optionInvalidErr()
}
if len(poll.Answers) > domain.MaxPollAnswers {
return nil, zero, optionsTooMuchErr()
}
snapshot := &domain.MessagePoll{
ID: pollID,
Question: question,
QuestionEntities: domainMessageEntities(poll.Question.Entities),
PublicVoters: poll.PublicVoters,
MultipleChoice: poll.MultipleChoice,
Quiz: poll.Quiz,
RevotingDisabled: poll.RevotingDisabled,
ShuffleAnswers: poll.ShuffleAnswers,
HideResultsUntilClose: poll.HideResultsUntilClose,
}
def := domain.PollDefinition{
ID: pollID,
CreatorUserID: creatorUserID,
PublicVoters: poll.PublicVoters,
MultipleChoice: poll.MultipleChoice,
Quiz: poll.Quiz,
RevotingDisabled: poll.RevotingDisabled,
HideResultsUntilClose: poll.HideResultsUntilClose,
}
// 答案两种形态pollAnswer 自带 option 键DrKLO Android 路径);
// inputPollAnswer 无 optionTDesktop 创建路径——option 键由服务端分配。
seen := make(map[string]struct{}, len(poll.Answers))
pendingOption := make([]bool, 0, len(poll.Answers))
for _, answerClass := range poll.Answers {
var text tg.TextWithEntities
var option []byte
var answerMedia *domain.MessageMedia
switch answer := answerClass.(type) {
case *tg.PollAnswer:
if answer.Media != nil {
// pollAnswer.media 是 MessageMedia服务端输出形态不是合法输入。
return nil, zero, mediaInvalidErr()
}
if len(answer.Option) == 0 || len(answer.Option) > maxPollOptionBytes {
return nil, zero, pollOptionInvalidErr()
}
text = answer.Text
option = append([]byte(nil), answer.Option...)
case *tg.InputPollAnswer:
// 答案配图TDesktop 创建面板可给每个答案附 photo/document
if input, ok := answer.GetMedia(); ok && input != nil {
media, err := r.resolvePollAuxMedia(ctx, creatorUserID, input)
if err != nil {
return nil, zero, err
}
answerMedia = media
}
text = answer.Text
default:
return nil, zero, pollAnswerInvalidErr()
}
if strings.TrimSpace(text.Text) == "" || utf8.RuneCountInString(text.Text) > domain.MaxPollAnswerTextLength {
return nil, zero, pollAnswerInvalidErr()
}
if len(text.Entities) > maxMessageEntityCount {
return nil, zero, limitInvalidErr()
}
if option != nil {
key := string(option)
if _, dup := seen[key]; dup {
return nil, zero, pollOptionInvalidErr()
}
seen[key] = struct{}{}
}
pendingOption = append(pendingOption, option == nil)
snapshot.Answers = append(snapshot.Answers, domain.MessagePollAnswer{
Text: text.Text,
Entities: domainMessageEntities(text.Entities),
Option: option,
Media: answerMedia,
})
}
// 为 inputPollAnswer 分配服务端 option 键:取未被显式键占用的最小单字节。
next := 0
for i := range snapshot.Answers {
if !pendingOption[i] {
continue
}
for next <= 0xFF {
key := []byte{byte(next)}
next++
if _, taken := seen[string(key)]; !taken {
seen[string(key)] = struct{}{}
snapshot.Answers[i].Option = key
break
}
}
if snapshot.Answers[i].Option == nil {
return nil, zero, pollOptionInvalidErr()
}
}
for _, answer := range snapshot.Answers {
def.Options = append(def.Options, answer.Option)
}
// quiz 机密correct_answersLayer 224+ 为答案下标)必须恰好 1 个且指向合法选项;
// solution 仅 quiz 可带。
correct, hasCorrect := in.GetCorrectAnswers()
solution, hasSolution := in.GetSolution()
if poll.Quiz {
if poll.MultipleChoice {
return nil, zero, mediaInvalidErr()
}
if !hasCorrect || len(correct) != 1 {
return nil, zero, quizCorrectAnswersInvalidErr()
}
for _, index := range correct {
if index < 0 || index >= len(def.Options) {
return nil, zero, quizCorrectAnswersInvalidErr()
}
def.CorrectOptions = append(def.CorrectOptions, append([]byte(nil), def.Options[index]...))
}
if hasSolution {
if strings.TrimSpace(solution) == "" || utf8.RuneCountInString(solution) > domain.MaxPollSolutionLength {
return nil, zero, mediaInvalidErr()
}
entities, _ := in.GetSolutionEntities()
if len(entities) > maxMessageEntityCount {
return nil, zero, limitInvalidErr()
}
def.Solution = solution
def.SolutionEntities = domainMessageEntities(entities)
}
} else if hasCorrect || hasSolution {
return nil, zero, mediaInvalidErr()
}
if _, ok := in.GetSolutionMedia(); ok {
// quiz 解释配图依赖 polls 权威表扩列(机密侧),当前未接入(矩阵 todo
return nil, zero, mediaInvalidErr()
}
if input, ok := in.GetAttachedMedia(); ok && input != nil {
// poll 题干配图messageMediaPoll.attached_media随快照落库。
media, err := r.resolvePollAuxMedia(ctx, creatorUserID, input)
if err != nil {
return nil, zero, err
}
snapshot.AttachedMedia = media
}
// close_period / close_date 互斥period 折算出 close_date 供服务端到点判定。
closePeriod, hasPeriod := poll.GetClosePeriod()
closeDate, hasDate := poll.GetCloseDate()
if hasPeriod && hasDate {
return nil, zero, mediaInvalidErr()
}
if hasPeriod {
if closePeriod < domain.MinPollClosePeriod || closePeriod > domain.MaxPollClosePeriod {
return nil, zero, mediaInvalidErr()
}
snapshot.ClosePeriod = closePeriod
snapshot.CloseDate = now + closePeriod
def.ClosePeriod = closePeriod
def.CloseDate = now + closePeriod
} else if hasDate {
if closeDate <= now || closeDate > now+domain.MaxPollClosePeriod+60 {
return nil, zero, mediaInvalidErr()
}
snapshot.CloseDate = closeDate
def.CloseDate = closeDate
}
return snapshot, def, nil
}
// tgPoll 把定义快照转成 tg.PollClosed/Results 由读路径 enrichment 决定)。
func tgPoll(p domain.MessagePoll) tg.Poll {
out := tg.Poll{
ID: p.ID,
Closed: p.Closed,
PublicVoters: p.PublicVoters,
MultipleChoice: p.MultipleChoice,
Quiz: p.Quiz,
RevotingDisabled: p.RevotingDisabled,
ShuffleAnswers: p.ShuffleAnswers,
HideResultsUntilClose: p.HideResultsUntilClose,
Question: tgTextWithEntities(p.Question, p.QuestionEntities),
Answers: make([]tg.PollAnswerClass, 0, len(p.Answers)),
}
for _, answer := range p.Answers {
item := &tg.PollAnswer{
Text: tgTextWithEntities(answer.Text, answer.Entities),
Option: answer.Option,
}
if answer.Media != nil {
item.SetMedia(tgMessageMedia(answer.Media))
}
out.Answers = append(out.Answers, item)
}
if p.ClosePeriod > 0 {
out.SetClosePeriod(p.ClosePeriod)
}
if p.CloseDate > 0 {
out.SetCloseDate(p.CloseDate)
}
return out
}
// tgPollResults 输出 viewer 视角结果;未 enrichResults==nil时回退 min客户端保留本地缓存
func tgPollResults(p domain.MessagePoll) tg.PollResults {
out := tg.PollResults{}
results := p.Results
if results == nil {
out.Min = true
return out
}
voters := make([]tg.PollAnswerVoters, 0, len(results.Voters))
for _, item := range results.Voters {
entry := tg.PollAnswerVoters{Chosen: item.Chosen, Correct: item.Correct, Option: item.Option}
entry.SetVoters(item.Voters)
voters = append(voters, entry)
}
out.SetResults(voters)
out.SetTotalVoters(results.TotalVoters)
if len(results.RecentVoters) > 0 {
peers := make([]tg.PeerClass, 0, len(results.RecentVoters))
for _, userID := range results.RecentVoters {
peers = append(peers, &tg.PeerUser{UserID: userID})
}
out.SetRecentVoters(peers)
}
if results.Solution != "" {
out.SetSolution(results.Solution)
out.SetSolutionEntities(tgMessageEntities(results.SolutionEntities))
}
return out
}
func tgTextWithEntities(text string, entities []domain.MessageEntity) tg.TextWithEntities {
converted := tgMessageEntities(entities)
if converted == nil {
converted = []tg.MessageEntityClass{}
}
return tg.TextWithEntities{Text: text, Entities: converted}
}
// tgUpdateMessagePoll 组装一条 updateMessagePollpoll 总是内联,避免客户端缓存缺失)。
func tgUpdateMessagePoll(peer domain.Peer, msgID int, poll *domain.MessagePoll) *tg.UpdateMessagePoll {
if poll == nil {
return nil
}
update := &tg.UpdateMessagePoll{
PollID: poll.ID,
Results: tgPollResults(*poll),
}
update.SetPoll(tgPoll(*poll))
if tgp := tgPeer(peer); tgp != nil && msgID > 0 {
update.SetPeer(tgp)
update.SetMsgID(msgID)
}
return update
}
// pollVotesNextOffset 编解码 getPollVotes 翻页 token"<date>,<user_id>")。
func pollVotesNextOffset(votes []domain.PollVote) string {
if len(votes) == 0 {
return ""
}
last := votes[len(votes)-1]
return strconv.Itoa(last.Date) + "," + strconv.FormatInt(last.UserID, 10)
}
func decodePollVotesOffset(offset string) (date int, userID int64, ok bool) {
if offset == "" {
return 0, 0, true
}
parts := strings.SplitN(offset, ",", 2)
if len(parts) != 2 {
return 0, 0, false
}
d, err1 := strconv.Atoi(parts[0])
u, err2 := strconv.ParseInt(parts[1], 10, 64)
if err1 != nil || err2 != nil || d < 0 || u < 0 {
return 0, 0, false
}
return d, u, true
}

View file

@ -0,0 +1,134 @@
package rpc
import (
"context"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// 本文件集中 Layer 227 富文本消息richMessage的 tg.* ↔ domain 转换。
// Phase 1仅支持 inputRichMessageblocks 形态HTML/Markdown 变体(需服务端解析为
// PageBlock尚未实现直接拒绝。blocks 以 TL 向量序列化为不透明字节存 domain详见
// domain.MessageRichMessage
// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)。
func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) {
var b bin.Buffer
b.PutVectorHeader(len(blocks))
for _, blk := range blocks {
if blk == nil {
return nil, mediaInvalidErr()
}
if err := blk.Encode(&b); err != nil {
return nil, err
}
}
return b.Buf, nil
}
// decodeRichBlocks 把 encodeRichBlocks 产生的字节还原为 []tg.PageBlockClass。
func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
if len(data) == 0 {
return nil, nil
}
b := &bin.Buffer{Buf: append([]byte(nil), data...)}
n, err := b.VectorHeader()
if err != nil {
return nil, err
}
out := make([]tg.PageBlockClass, 0, n)
for i := 0; i < n; i++ {
blk, err := tg.DecodePageBlock(b)
if err != nil {
return nil, err
}
out = append(out, blk)
}
return out, nil
}
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
// 序列化 blocks + 按 id 解析内嵌 photos/documents复用 sendMedia 同款媒体解析)。
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputRichMessageClass) (*domain.MessageRichMessage, error) {
if input == nil {
return nil, nil
}
in, ok := input.(*tg.InputRichMessage)
if !ok {
// Phase 1HTML/Markdown 变体需服务端解析为 PageBlock尚未支持。
return nil, mediaInvalidErr()
}
if r.deps.Files == nil {
return nil, notImplementedErr()
}
blocks, err := encodeRichBlocks(in.Blocks)
if err != nil {
return nil, err
}
rich := &domain.MessageRichMessage{
Rtl: in.Rtl,
Blocks: blocks,
}
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)
}
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)
}
if rich.IsZero() {
return nil, nil
}
return rich, nil
}
// tgRichMessage 把 domain 富文本快照投影为 tg.RichMessage反序列化 blocks + 复用
// tgPhoto/tgDocument 投影内嵌媒体)。空载荷或 blocks 解码失败返回 (nil, err)。
func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
if m.IsZero() {
return nil, nil
}
blocks, err := decodeRichBlocks(m.Blocks)
if err != nil {
return nil, err
}
out := &tg.RichMessage{
Rtl: m.Rtl,
Part: m.Part,
Blocks: blocks,
Photos: make([]tg.PhotoClass, 0, len(m.Photos)),
Documents: make([]tg.DocumentClass, 0, len(m.Documents)),
}
for _, p := range m.Photos {
out.Photos = append(out.Photos, tgPhoto(p))
}
for _, d := range m.Documents {
out.Documents = append(out.Documents, tgDocument(d))
}
return out, nil
}

View file

@ -0,0 +1,439 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func tgStoriesAllStories(viewerUserID int64, list domain.StoryList) tg.StoriesAllStoriesClass {
return &tg.StoriesAllStories{
HasMore: list.HasMore,
Count: list.Count,
State: list.State,
PeerStories: tgPeerStoriesList(list.Peers),
Chats: tgChannels(viewerUserID, list.Channels),
Users: tgUsersForViewer(viewerUserID, list.Users),
StealthMode: tg.StoriesStealthMode{},
}
}
func tgStoriesAllStoriesNotModified(state string) tg.StoriesAllStoriesClass {
return &tg.StoriesAllStoriesNotModified{
State: state,
StealthMode: tg.StoriesStealthMode{},
}
}
func tgStoriesStories(viewerUserID int64, list domain.StoryList) *tg.StoriesStories {
out := &tg.StoriesStories{
Count: list.Count,
Stories: tgStoryItems(list.Stories),
Chats: tgChannels(viewerUserID, list.Channels),
Users: tgUsersForViewer(viewerUserID, list.Users),
}
if len(list.PinnedToTop) > 0 {
out.SetPinnedToTop(append([]int(nil), list.PinnedToTop...))
}
return out
}
func tgStoriesPeerStories(viewerUserID int64, peerStories domain.PeerStories) *tg.StoriesPeerStories {
return &tg.StoriesPeerStories{
Stories: tgPeerStories(peerStories),
Chats: tgChannels(viewerUserID, peerStories.Channels),
Users: tgUsersForViewer(viewerUserID, peerStories.Users),
}
}
func tgPeerStoriesList(items []domain.PeerStories) []tg.PeerStories {
out := make([]tg.PeerStories, 0, len(items))
for _, item := range items {
out = append(out, tgPeerStories(item))
}
return out
}
func tgPeerStories(item domain.PeerStories) tg.PeerStories {
out := tg.PeerStories{
Peer: tgPeer(item.Peer),
Stories: tgStoryItems(item.Stories),
}
if item.MaxReadID > 0 {
out.SetMaxReadID(item.MaxReadID)
}
return out
}
func tgStoryItems(stories []domain.Story) []tg.StoryItemClass {
out := make([]tg.StoryItemClass, 0, len(stories))
for _, story := range stories {
out = append(out, tgStoryItem(story))
}
return out
}
func tgStoryItem(story domain.Story) tg.StoryItemClass {
if story.Deleted {
return &tg.StoryItemDeleted{ID: story.ID}
}
media := tgMessageMedia(story.Media)
out := &tg.StoryItem{
Pinned: story.Pinned,
Public: story.Public,
CloseFriends: story.CloseFriends,
Noforwards: story.NoForwards,
Edited: story.Edited,
Contacts: story.Contacts,
SelectedContacts: story.SelectedContacts,
Out: story.Out,
ID: story.ID,
Date: story.Date,
ExpireDate: story.ExpireDate,
Media: media,
}
if peer := tgPeer(story.Owner); peer != nil {
out.SetFromID(peer)
}
if story.Caption != "" {
out.SetCaption(story.Caption)
}
if len(story.Entities) > 0 {
out.SetEntities(tgMessageEntities(story.Entities))
}
if len(story.MediaAreas) > 0 {
out.SetMediaAreas(tgStoryMediaAreas(story.MediaAreas))
}
if forward := tgStoryForward(story.Forward); forward != nil {
out.SetFwdFrom(*forward)
}
if story.Out && len(story.PrivacyRules) > 0 {
out.SetPrivacy(tgPrivacyRules(story.PrivacyRules))
}
if !storyViewsEmpty(story.Views) {
out.SetViews(tgStoryViews(story.Views))
}
if story.SentReaction != nil {
out.SetSentReaction(tgMessageReaction(*story.SentReaction))
}
return out
}
func tgStoryForward(in *domain.StoryForward) *tg.StoryFwdHeader {
if in == nil {
return nil
}
out := &tg.StoryFwdHeader{
Modified: in.Modified,
}
if peer := tgPeer(in.From); peer != nil {
out.SetFrom(peer)
}
if in.FromName != "" {
out.SetFromName(in.FromName)
}
if in.StoryID > 0 {
out.SetStoryID(in.StoryID)
}
if out.From == nil && out.FromName == "" && out.StoryID == 0 && !out.Modified {
return nil
}
return out
}
func tgStoryMediaAreas(in []domain.StoryMediaArea) []tg.MediaAreaClass {
out := make([]tg.MediaAreaClass, 0, len(in))
for _, area := range in {
switch area.Kind {
case domain.StoryMediaAreaSuggestedReaction:
if area.Reaction == nil {
continue
}
reaction := tgMessageReaction(*area.Reaction)
if reaction == nil {
continue
}
out = append(out, &tg.MediaAreaSuggestedReaction{
Dark: area.Dark,
Flipped: area.Flipped,
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
Reaction: reaction,
})
case domain.StoryMediaAreaURL:
if area.URL == "" {
continue
}
out = append(out, &tg.MediaAreaURL{
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
URL: area.URL,
})
case domain.StoryMediaAreaGeoPoint:
if area.Geo == nil {
continue
}
geo := &tg.MediaAreaGeoPoint{
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
Geo: tgGeoPoint(*area.Geo),
}
if address := tgStoryGeoPointAddress(area.GeoAddress); address != nil {
geo.SetAddress(*address)
}
out = append(out, geo)
case domain.StoryMediaAreaVenue:
if area.Venue == nil {
continue
}
out = append(out, &tg.MediaAreaVenue{
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
Geo: tgGeoPoint(area.Venue.Geo),
Title: area.Venue.Title,
Address: area.Venue.Address,
Provider: area.Venue.Provider,
VenueID: area.Venue.VenueID,
VenueType: area.Venue.VenueType,
})
case domain.StoryMediaAreaWeather:
if area.WeatherEmoji == "" {
continue
}
out = append(out, &tg.MediaAreaWeather{
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
Emoji: area.WeatherEmoji,
TemperatureC: area.TemperatureC,
Color: area.Color,
})
case domain.StoryMediaAreaChannelPost:
if area.ChannelID <= 0 || area.MsgID <= 0 {
continue
}
out = append(out, &tg.MediaAreaChannelPost{
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
ChannelID: area.ChannelID,
MsgID: area.MsgID,
})
case domain.StoryMediaAreaStarGift:
if area.StarGiftSlug == "" {
continue
}
out = append(out, &tg.MediaAreaStarGift{
Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates),
Slug: area.StarGiftSlug,
})
}
}
return out
}
func tgStoryMediaAreaCoordinates(in domain.StoryMediaAreaCoordinates) tg.MediaAreaCoordinates {
out := tg.MediaAreaCoordinates{
X: in.X,
Y: in.Y,
W: in.W,
H: in.H,
Rotation: in.Rotation,
}
if in.HasRadius {
out.SetRadius(in.Radius)
}
return out
}
func tgStoryGeoPointAddress(in *domain.StoryGeoPointAddress) *tg.GeoPointAddress {
if in == nil || in.CountryISO2 == "" {
return nil
}
out := &tg.GeoPointAddress{CountryISO2: in.CountryISO2}
if in.State != "" {
out.SetState(in.State)
}
if in.City != "" {
out.SetCity(in.City)
}
if in.Street != "" {
out.SetStreet(in.Street)
}
return out
}
func tgStoryViews(views domain.StoryViews) tg.StoryViews {
out := tg.StoryViews{
HasViewers: views.HasViewers,
ViewsCount: views.ViewsCount,
}
if views.ForwardsCount > 0 {
out.SetForwardsCount(views.ForwardsCount)
}
if len(views.Reactions) > 0 {
out.SetReactions(tgStoryReactionCounts(views.Reactions))
}
if views.ReactionsCount > 0 {
out.SetReactionsCount(views.ReactionsCount)
}
if len(views.RecentViewers) > 0 {
out.SetRecentViewers(append([]int64(nil), views.RecentViewers...))
}
return out
}
func storyViewsEmpty(views domain.StoryViews) bool {
return !views.HasViewers &&
views.ViewsCount == 0 &&
views.ForwardsCount == 0 &&
views.ReactionsCount == 0 &&
len(views.Reactions) == 0 &&
len(views.RecentViewers) == 0
}
func tgStoryReactionCounts(in []domain.ChannelMessageReactionCount) []tg.ReactionCount {
out := make([]tg.ReactionCount, 0, len(in))
for _, count := range in {
if count.Count <= 0 {
continue
}
item := tg.ReactionCount{
Reaction: tgMessageReaction(count.Reaction),
Count: count.Count,
}
if count.ChosenOrder > 0 {
item.SetChosenOrder(count.ChosenOrder)
}
out = append(out, item)
}
return out
}
func tgRecentStories(in []domain.RecentStory) []tg.RecentStory {
out := make([]tg.RecentStory, len(in))
for i, story := range in {
out[i] = tg.RecentStory{Live: story.Live}
if story.MaxID > 0 {
out[i].SetMaxID(story.MaxID)
}
}
return out
}
func tgReadStoryUpdates(states []domain.StoryReadState, date int) tg.UpdatesClass {
updates := make([]tg.UpdateClass, 0, len(states))
for _, state := range states {
if state.MaxReadID <= 0 {
continue
}
peer := tgPeer(state.Peer)
if peer == nil {
continue
}
updates = append(updates, &tg.UpdateReadStories{Peer: peer, MaxID: state.MaxReadID})
}
return &tg.Updates{
Updates: updates,
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
}
func tgStoryViewsList(viewerUserID int64, list domain.StoryViewList, users []domain.User) *tg.StoriesStoryViewsList {
out := &tg.StoriesStoryViewsList{
Count: list.Count,
ViewsCount: list.ViewsCount,
ForwardsCount: list.ForwardsCount,
ReactionsCount: list.ReactionsCount,
Views: tgStoryViewItems(viewerUserID, list.Views),
Chats: []tg.ChatClass{},
Users: tgUsersForViewer(viewerUserID, users),
}
if list.NextOffset != "" {
out.SetNextOffset(list.NextOffset)
}
return out
}
func tgStoryViewItems(viewerUserID int64, views []domain.StoryView) []tg.StoryViewClass {
out := make([]tg.StoryViewClass, 0, len(views))
for _, view := range views {
if view.PublicForward != nil {
msg := tgChannelMessage(viewerUserID, view.PublicForward.Message)
if msg == nil {
continue
}
out = append(out, &tg.StoryViewPublicForward{
Blocked: view.Blocked,
BlockedMyStoriesFrom: view.BlockedMyStoriesFrom,
Message: msg,
})
continue
}
if view.Repost != nil {
item := &tg.StoryViewPublicRepost{
Blocked: view.Blocked,
BlockedMyStoriesFrom: view.BlockedMyStoriesFrom,
PeerID: tgPeer(view.Repost.Owner),
Story: tgStoryItem(*view.Repost),
}
out = append(out, item)
continue
}
item := &tg.StoryView{
Blocked: view.Blocked,
BlockedMyStoriesFrom: view.BlockedMyStoriesFrom,
UserID: view.ViewerID,
Date: view.Date,
}
if view.Reaction != nil {
if reaction := tgMessageReaction(*view.Reaction); reaction != nil {
item.SetReaction(reaction)
}
}
out = append(out, item)
}
return out
}
func tgStoryReactionsList(viewerUserID int64, list domain.StoryReactionList, users []domain.User) *tg.StoriesStoryReactionsList {
out := &tg.StoriesStoryReactionsList{
Count: list.Count,
Reactions: tgStoryReactionItems(viewerUserID, list.Reactions),
Chats: []tg.ChatClass{},
Users: tgUsersForViewer(viewerUserID, users),
}
if list.NextOffset != "" {
out.SetNextOffset(list.NextOffset)
}
return out
}
func tgStoryReactionItems(viewerUserID int64, views []domain.StoryView) []tg.StoryReactionClass {
out := make([]tg.StoryReactionClass, 0, len(views))
for _, view := range views {
if view.PublicForward != nil {
msg := tgChannelMessage(viewerUserID, view.PublicForward.Message)
if msg == nil {
continue
}
out = append(out, &tg.StoryReactionPublicForward{Message: msg})
continue
}
if view.Repost != nil {
out = append(out, &tg.StoryReactionPublicRepost{
PeerID: tgPeer(view.Repost.Owner),
Story: tgStoryItem(*view.Repost),
})
continue
}
if view.Reaction == nil {
continue
}
reaction := tgMessageReaction(*view.Reaction)
if reaction == nil {
continue
}
out = append(out, &tg.StoryReaction{
PeerID: &tg.PeerUser{UserID: view.ViewerID},
Date: view.Date,
Reaction: reaction,
})
}
return out
}

View file

@ -61,3 +61,86 @@ func TestTGMessagesDialogsIncludesUserProfilePhoto(t *testing.T) {
t.Fatalf("photo = %+v ok=%v, want userProfilePhoto 9301/2/[9 10]", peer.Photo, ok)
}
}
func TestTGPhotoRejectsVideoOnlyAvatar(t *testing.T) {
photo := tgPhoto(domain.Photo{
ID: 9302,
AccessHash: 7,
DCID: 2,
Sizes: []domain.PhotoSize{
{Kind: domain.PhotoSizeKindVideo, Type: "u", W: 640, H: 640, Size: 1024},
{Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: 99, BackgroundColors: []int{0xffffff}},
},
})
if _, ok := photo.(*tg.PhotoEmpty); !ok {
t.Fatalf("photo = %T %+v, want PhotoEmpty for video-only avatar", photo, photo)
}
}
func TestTGPhotoKeepsAnimatedAvatarWithStaticSizes(t *testing.T) {
photo := tgPhoto(domain.Photo{
ID: 9303,
AccessHash: 7,
DCID: 2,
Sizes: []domain.PhotoSize{
{Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160, Size: 1024},
{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 1024},
{Kind: domain.PhotoSizeKindVideo, Type: "u", W: 640, H: 640, Size: 2048},
{Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: 99, BackgroundColors: []int{0xffffff}},
},
})
full, ok := photo.(*tg.Photo)
if !ok {
t.Fatalf("photo = %T %+v, want Photo", photo, photo)
}
if len(full.Sizes) != 2 || len(full.VideoSizes) != 2 {
t.Fatalf("sizes = %d video_sizes = %d, want 2/2", len(full.Sizes), len(full.VideoSizes))
}
}
func TestMessageEntitiesRoundTripAllStyledTypes(t *testing.T) {
const viewerID int64 = 1001
in := []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 1},
&tg.MessageEntityItalic{Offset: 1, Length: 2},
&tg.MessageEntityUnderline{Offset: 2, Length: 3},
&tg.MessageEntityStrike{Offset: 3, Length: 4},
&tg.MessageEntityCode{Offset: 4, Length: 5},
&tg.MessageEntityPre{Offset: 5, Length: 6, Language: "go"},
&tg.MessageEntityTextURL{Offset: 6, Length: 7, URL: "https://example.org"},
&tg.MessageEntityMentionName{Offset: 7, Length: 8, UserID: 1002},
&tg.InputMessageEntityMentionName{Offset: 8, Length: 9, UserID: &tg.InputUserSelf{}},
&tg.MessageEntitySpoiler{Offset: 9, Length: 10},
&tg.MessageEntityBlockquote{Offset: 10, Length: 11, Collapsed: true},
&tg.MessageEntityCustomEmoji{Offset: 11, Length: 12, DocumentID: 777},
&tg.MessageEntityMention{Offset: 12, Length: 13},
&tg.MessageEntityHashtag{Offset: 13, Length: 14},
&tg.MessageEntityURL{Offset: 14, Length: 15},
}
converted := domainMessageEntitiesForViewer(viewerID, in)
if len(converted) != len(in) {
t.Fatalf("converted %d entities, want %d: no styled entity may be dropped", len(converted), len(in))
}
if converted[6].URL != "https://example.org" {
t.Fatalf("text_url URL = %q, want preserved", converted[6].URL)
}
if converted[7].UserID != 1002 {
t.Fatalf("mention_name user = %d, want 1002", converted[7].UserID)
}
if converted[8].UserID != viewerID {
t.Fatalf("input mention self user = %d, want viewer %d", converted[8].UserID, viewerID)
}
if converted[11].DocumentID != 777 {
t.Fatalf("custom emoji document = %d, want 777", converted[11].DocumentID)
}
out := tgMessageEntities(converted)
if len(out) != len(in) {
t.Fatalf("round-trip produced %d entities, want %d", len(out), len(in))
}
if quote, ok := out[10].(*tg.MessageEntityBlockquote); !ok || !quote.Collapsed {
t.Fatalf("blockquote = %#v, want collapsed flag preserved", out[10])
}
if mention, ok := out[8].(*tg.MessageEntityMentionName); !ok || mention.UserID != viewerID {
t.Fatalf("self mention round-trip = %#v, want messageEntityMentionName self", out[8])
}
}

View file

@ -0,0 +1,220 @@
package rpc
import (
"context"
"sort"
"github.com/gotd/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)
}
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
}
return int64(h & 0x7fffffffffffffff)
}
// themeRefFromInput 把 tg.InputThemeClass(inputTheme / inputThemeSlug)转成 domain.ThemeRef。
func themeRefFromInput(in tg.InputThemeClass) (domain.ThemeRef, bool) {
switch v := in.(type) {
case *tg.InputTheme:
if v.ID == 0 {
return domain.ThemeRef{}, false
}
return domain.ThemeRef{ID: v.ID, AccessHash: v.AccessHash}, true
case *tg.InputThemeSlug:
if v.Slug == "" {
return domain.ThemeRef{}, false
}
return domain.ThemeRef{Slug: v.Slug}, true
default:
return domain.ThemeRef{}, false
}
}
func domainBaseThemeFromInput(in tg.BaseThemeClass) domain.ThemeBaseKind {
switch in.(type) {
case *tg.BaseThemeClassic:
return domain.ThemeBaseClassic
case *tg.BaseThemeDay:
return domain.ThemeBaseDay
case *tg.BaseThemeNight:
return domain.ThemeBaseNight
case *tg.BaseThemeTinted:
return domain.ThemeBaseTinted
case *tg.BaseThemeArctic:
return domain.ThemeBaseArctic
default:
return domain.ThemeBaseClassic
}
}
func tgBaseTheme(kind domain.ThemeBaseKind) tg.BaseThemeClass {
switch kind {
case domain.ThemeBaseDay:
return &tg.BaseThemeDay{}
case domain.ThemeBaseNight:
return &tg.BaseThemeNight{}
case domain.ThemeBaseTinted:
return &tg.BaseThemeTinted{}
case domain.ThemeBaseArctic:
return &tg.BaseThemeArctic{}
default:
return &tg.BaseThemeClassic{}
}
}
// domainThemeSettingsFromInput 解析 createTheme/updateTheme 传入的 settings 向量。
func domainThemeSettingsFromInput(in []tg.InputThemeSettings) []domain.ThemeSettingsSpec {
if len(in) == 0 {
return nil
}
out := make([]domain.ThemeSettingsSpec, 0, len(in))
for _, s := range in {
spec := domain.ThemeSettingsSpec{
BaseTheme: domainBaseThemeFromInput(s.BaseTheme),
AccentColor: s.GetAccentColor(),
MessageColorsAnimated: s.GetMessageColorsAnimated(),
}
if v, ok := s.GetOutboxAccentColor(); ok {
spec.OutboxAccentColor = v
spec.HasOutboxAccent = true
}
if v, ok := s.GetMessageColors(); ok {
spec.MessageColors = append([]int(nil), v...)
}
// flag bit1 同时门控 wallpaper 与 wallpaper_settings;渐变色在 wallpaper_settings 里。
if wp, ok := s.GetWallpaperSettings(); ok {
spec.Wallpaper = domainThemeWallpaperFromInput(wp)
}
out = append(out, spec)
}
return out
}
func domainThemeWallpaperFromInput(wp tg.WallPaperSettings) *domain.ThemeWallpaperSpec {
out := &domain.ThemeWallpaperSpec{Blur: wp.Blur, Motion: wp.Motion}
colors := make([]int, 0, 4)
if v, ok := wp.GetBackgroundColor(); ok {
colors = append(colors, v)
}
if v, ok := wp.GetSecondBackgroundColor(); ok {
colors = append(colors, v)
}
if v, ok := wp.GetThirdBackgroundColor(); ok {
colors = append(colors, v)
}
if v, ok := wp.GetFourthBackgroundColor(); ok {
colors = append(colors, v)
}
if len(colors) > 0 {
out.BackgroundColors = colors
}
if v, ok := wp.GetIntensity(); ok {
out.Intensity = v
}
if v, ok := wp.GetRotation(); ok {
out.Rotation = v
}
if v, ok := wp.GetEmoticon(); ok {
out.Emoticon = v
}
return out
}
// tgTheme 把 domain.Theme 投影为 tg.Theme。viewerUserID 决定 creator 标志(决定客户端
// 下次上传走 update 而非 create)。完整自定义主题靠 DocumentID 解析出可下载 Document。
func (r *Router) tgTheme(ctx context.Context, t domain.Theme, viewerUserID int64) *tg.Theme {
out := &tg.Theme{
ID: t.ID,
AccessHash: t.AccessHash,
Slug: t.Slug,
Title: t.Title,
}
if t.IsCreator(viewerUserID) {
out.SetCreator(true)
}
if t.ForChat {
out.SetForChat(true)
}
if t.Emoticon != "" {
out.SetEmoticon(t.Emoticon)
}
out.SetInstallsCount(t.InstallsCount)
if len(t.Settings) > 0 {
out.SetSettings(tgThemeSettingsList(t.Settings))
}
if t.DocumentID != 0 && r.deps.Files != nil {
if doc, ok, err := r.deps.Files.GetDocument(ctx, t.DocumentID); err == nil && ok {
out.SetDocument(tgDocument(doc))
}
}
return out
}
func tgThemeSettingsList(in []domain.ThemeSettingsSpec) []tg.ThemeSettings {
out := make([]tg.ThemeSettings, 0, len(in))
for _, s := range in {
ts := tg.ThemeSettings{
BaseTheme: tgBaseTheme(s.BaseTheme),
AccentColor: s.AccentColor,
}
if s.HasOutboxAccent {
ts.SetOutboxAccentColor(s.OutboxAccentColor)
}
if len(s.MessageColors) > 0 {
ts.SetMessageColors(append([]int(nil), s.MessageColors...))
}
if s.MessageColorsAnimated {
ts.SetMessageColorsAnimated(true)
}
if s.Wallpaper != nil {
ts.SetWallpaper(tgThemeWallpaper(s.Wallpaper))
}
out = append(out, ts)
}
return out
}
func tgThemeWallpaper(wp *domain.ThemeWallpaperSpec) tg.WallPaperClass {
settings := tg.WallPaperSettings{Blur: wp.Blur, Motion: wp.Motion}
colors := wp.BackgroundColors
if len(colors) > 0 {
settings.SetBackgroundColor(colors[0])
}
if len(colors) > 1 {
settings.SetSecondBackgroundColor(colors[1])
}
if len(colors) > 2 {
settings.SetThirdBackgroundColor(colors[2])
}
if len(colors) > 3 {
settings.SetFourthBackgroundColor(colors[3])
}
if wp.Intensity != 0 {
settings.SetIntensity(wp.Intensity)
}
if wp.Rotation != 0 {
settings.SetRotation(wp.Rotation)
}
if wp.Emoticon != "" {
settings.SetEmoticon(wp.Emoticon)
}
out := &tg.WallPaperNoFile{}
if wp.Dark {
out.SetDark(true)
}
out.SetSettings(settings)
return out
}

View file

@ -0,0 +1,595 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func tgUpdateState(st domain.UpdateState) tg.UpdatesState {
return tg.UpdatesState{Pts: st.Pts, Qts: st.Qts, Date: st.Date, Seq: st.Seq}
}
func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.UpdatesDifferenceClass {
out := &tg.UpdatesDifference{
NewMessages: make([]tg.MessageClass, 0, len(diff.Events)),
OtherUpdates: make([]tg.UpdateClass, 0, len(diff.Events)),
Users: make([]tg.UserClass, 0, 1),
Chats: make([]tg.ChatClass, 0, 1),
}
seenUsers := make(map[int64]struct{})
seenChats := make(map[int64]struct{})
for _, event := range diff.Events {
addUsers(out, seenUsers, viewerUserID, event.Users)
addChannels(out, seenChats, event.UserID, event.Channels)
switch event.Type {
case domain.UpdateEventNewMessage:
if msg := tgMessage(event.Message); msg != nil {
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
}
case domain.UpdateEventReadHistoryInbox:
if update := tgReadHistoryInboxUpdate(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
}
case domain.UpdateEventReadHistoryOutbox:
if update := tgReadHistoryOutboxUpdate(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
}
case domain.UpdateEventMessageReactions, domain.UpdateEventMessagePoll:
// 同时下发消息快照(含最新聚合)与对应通知 update事件无 TL pts
// pts 推进靠 difference state 本身。
if msg := tgMessage(event.Message); msg != nil {
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
}
if update := tgOtherUpdateFromEvent(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
}
default:
if update := tgOtherUpdateFromEvent(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
}
}
}
for _, nudge := range diff.ChannelNudges {
if nudge.ChannelID == 0 {
continue
}
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 {
update.SetPts(nudge.Pts)
}
out.OtherUpdates = append(out.OtherUpdates, update)
if nudge.Channel != nil && nudge.Channel.Channel.ID != 0 {
addChannelNudgeChat(out, seenChats, tgChannelChatForView(viewerUserID, *nudge.Channel))
}
}
// Partial连续事件被 limit 截断、后面还有 → updates.differenceSlice客户端据 IntermediateState 续拉。
if diff.Partial {
return &tg.UpdatesDifferenceSlice{
NewMessages: out.NewMessages,
OtherUpdates: out.OtherUpdates,
Chats: out.Chats,
Users: out.Users,
IntermediateState: tgUpdateState(diff.State),
}
}
out.State = tgUpdateState(diff.State)
return out
}
func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.UpdatesChannelDifferenceClass {
if diff.TooLong {
messages := make([]tg.MessageClass, 0, len(diff.NewMessages))
for _, msg := range diff.NewMessages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
messages = append(messages, item)
}
}
dialog := tgDialog(domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: diff.Channel.ID},
TopMessage: diff.Dialog.TopMessageID,
TopMessageDate: diff.Dialog.TopMessageDate,
ReadInboxMaxID: diff.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: diff.Dialog.ReadOutboxMaxID,
UnreadCount: diff.Dialog.UnreadCount,
UnreadMentions: diff.Dialog.UnreadMentions,
UnreadReactions: diff.Dialog.UnreadReactions,
UnreadMark: diff.Dialog.UnreadMark,
FolderID: diff.Dialog.FolderID,
})
if dialog != nil {
dialog.SetPts(diff.Pts)
}
return &tg.UpdatesChannelDifferenceTooLong{
Final: diff.Final,
Timeout: diff.Timeout,
Dialog: dialog,
Messages: messages,
Chats: tgChannelDifferenceChats(viewerUserID, diff),
Users: tgUsersForViewer(viewerUserID, diff.Users),
}
}
if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 {
return &tg.UpdatesChannelDifferenceEmpty{
Final: diff.Final,
Pts: diff.Pts,
Timeout: diff.Timeout,
}
}
messages := make([]tg.MessageClass, 0, len(diff.NewMessages))
for _, msg := range diff.NewMessages {
if item := tgChannelMessage(viewerUserID, msg); item != nil {
messages = append(messages, item)
}
}
updates := make([]tg.UpdateClass, 0, len(diff.OtherUpdates))
// 断线后经差量首次看到自己消息时updateMessageID 让客户端把本地
// pending 按 random_id 对账,避免短暂的重复气泡。
for _, msg := range diff.NewMessages {
if msg.SenderUserID == viewerUserID && viewerUserID != 0 && msg.RandomID != 0 && msg.ID > 0 {
updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: msg.RandomID})
}
}
for _, event := range diff.OtherUpdates {
if update := tgChannelUpdate(viewerUserID, event); update != nil {
updates = append(updates, update)
}
}
chats := tgChannelDifferenceChats(viewerUserID, diff)
users := tgUsersForViewer(viewerUserID, diff.Users)
return &tg.UpdatesChannelDifference{
Final: diff.Final,
Pts: diff.Pts,
Timeout: diff.Timeout,
NewMessages: messages,
OtherUpdates: updates,
Chats: chats,
Users: users,
}
}
func tgChannelDifferenceChats(viewerUserID int64, diff domain.ChannelDifference) []tg.ChatClass {
return tgChannelChatsWithPrimarySelf(viewerUserID, diff.Channel, diff.Channels, diff.Self)
}
func tgChannelUpdate(viewerUserID int64, event domain.ChannelUpdateEvent) tg.UpdateClass {
switch event.Type {
case domain.ChannelUpdateNewMessage:
msg := tgChannelMessage(viewerUserID, event.Message)
if msg == nil {
return nil
}
return &tg.UpdateNewChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount}
case domain.ChannelUpdateEditMessage:
msg := tgChannelMessage(viewerUserID, event.Message)
if msg == nil {
return nil
}
return &tg.UpdateEditChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount}
case domain.ChannelUpdateWebPage:
// 频道链接预览就地替换updateChannelWebPage 仅携已解析 webPage按 id 关联消息里的
// pending 占位),客户端就地换卡片、不触碰 edit_date。
if event.Message.Media == nil || event.Message.Media.WebPage == nil {
return nil
}
return &tg.UpdateChannelWebPage{
ChannelID: event.ChannelID,
Webpage: tgWebPage(*event.Message.Media.WebPage),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.ChannelUpdateDeleteMessages:
if len(event.MessageIDs) == 0 {
return nil
}
return &tg.UpdateDeleteChannelMessages{
ChannelID: event.ChannelID,
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.ChannelUpdatePinnedMessages:
if len(event.MessageIDs) == 0 {
return nil
}
return &tg.UpdatePinnedChannelMessages{
Pinned: event.Pinned,
ChannelID: event.ChannelID,
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.ChannelUpdateParticipant:
userID := event.Participant.UserID
if userID == 0 {
userID = event.Previous.UserID
}
if userID == 0 {
return nil
}
actorID := event.SenderUserID
if actorID == 0 {
actorID = userID
}
update := &tg.UpdateChannelParticipant{
ChannelID: event.ChannelID,
Date: event.Date,
ActorID: actorID,
UserID: userID,
}
if event.Previous.UserID != 0 {
update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Previous))
}
if event.Participant.UserID != 0 {
update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Participant))
}
return update
default:
return nil
}
}
func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
switch event.Type {
case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil
}
return &tg.UpdateChannel{ChannelID: event.Peer.ID}
case domain.UpdateEventStory:
peer := tgPeer(event.Peer)
if peer == nil {
peer = tgPeer(event.Story.Owner)
}
if peer == nil || event.Story.ID == 0 {
return nil
}
return &tg.UpdateStory{Peer: peer, Story: tgStoryItem(event.Story)}
case domain.UpdateEventReadStories:
peer := tgPeer(event.Peer)
if peer == nil || event.MaxID <= 0 {
return nil
}
return &tg.UpdateReadStories{Peer: peer, MaxID: event.MaxID}
case domain.UpdateEventSentStoryReaction:
peer := tgPeer(event.Peer)
if peer == nil || event.MaxID <= 0 {
return nil
}
reaction := tg.ReactionClass(&tg.ReactionEmpty{})
if event.Reaction != nil {
reaction = tgMessageReaction(*event.Reaction)
}
return &tg.UpdateSentStoryReaction{Peer: peer, StoryID: event.MaxID, Reaction: reaction}
case domain.UpdateEventNewStoryReaction:
peer := tgPeer(event.Peer)
if peer == nil || event.MaxID <= 0 || event.Reaction == nil {
return nil
}
return &tg.UpdateNewStoryReaction{Peer: peer, StoryID: event.MaxID, Reaction: tgMessageReaction(*event.Reaction)}
case domain.UpdateEventQuickReplies:
return &tg.UpdateQuickReplies{QuickReplies: tgQuickReplies(event.QuickReplies)}
case domain.UpdateEventNewQuickReply:
return &tg.UpdateNewQuickReply{QuickReply: tgQuickReply(event.QuickReply)}
case domain.UpdateEventDeleteQuickReply:
if event.MaxID <= 0 {
return nil
}
return &tg.UpdateDeleteQuickReply{ShortcutID: event.MaxID}
case domain.UpdateEventQuickReplyMessage:
msg := tgQuickReplyMessage(event.QuickReplyMessage)
if msg == nil {
return nil
}
return &tg.UpdateQuickReplyMessage{Message: msg}
case domain.UpdateEventDeleteQuickReplyMessages:
if event.MaxID <= 0 || len(event.MessageIDs) == 0 {
return nil
}
return &tg.UpdateDeleteQuickReplyMessages{
ShortcutID: event.MaxID,
Messages: append([]int(nil), event.MessageIDs...),
}
case domain.UpdateEventContactsReset:
return &tg.UpdateContactsReset{}
case domain.UpdateEventDialogPinned:
// archive folder 行自身的置顶peer 是 dialogPeerFolder 且绝不能带
// folder_id flagTDesktop Folder::applyPinnedUpdate 视其为
// "Nested folders" 错误)。
if event.Peer.Type == domain.PeerTypeFolder {
if event.Peer.ID == 0 {
return nil
}
return &tg.UpdateDialogPinned{Pinned: event.Bool, Peer: &tg.DialogPeerFolder{FolderID: int(event.Peer.ID)}}
}
peer := tgDialogPeer(event.Peer)
if peer == nil {
return nil
}
// FolderID 零值不编码 flagEncodeBare 自动 SetFlags归档内置顶
// 必须带 folder_id=1否则离线重放会把它应用到主列表。
return &tg.UpdateDialogPinned{Pinned: event.Bool, Peer: peer, FolderID: event.FolderID}
case domain.UpdateEventPinnedDialogs:
update := &tg.UpdatePinnedDialogs{FolderID: event.FolderID}
if len(event.Peers) > 0 {
update.Order = tgDialogPeers(event.Peers)
}
return update
case domain.UpdateEventSavedDialogPinned:
peer := tgDialogPeer(event.Peer)
if peer == nil {
return nil
}
return &tg.UpdateSavedDialogPinned{Pinned: event.Bool, Peer: peer}
case domain.UpdateEventPinnedSavedDialogs:
update := &tg.UpdatePinnedSavedDialogs{}
if len(event.Peers) > 0 {
update.SetOrder(tgDialogPeers(event.Peers))
}
return update
case domain.UpdateEventDialogUnreadMark:
peer := tgDialogPeer(event.Peer)
if peer == nil {
return nil
}
return &tg.UpdateDialogUnreadMark{Unread: event.Bool, Peer: peer}
case domain.UpdateEventDraftMessage:
peer := tgPeer(event.Peer)
if peer == nil {
return nil
}
// Draft 由 enrichDraftMessageEvent 按当前权威态填充nil/空 = 草稿已删。
var draft tg.DraftMessageClass
if event.Draft != nil && !event.Draft.Empty() {
draft = tgDialogDraft(*event.Draft)
} else {
empty := &tg.DraftMessageEmpty{}
if event.Date > 0 {
empty.SetDate(event.Date)
}
draft = empty
}
update := &tg.UpdateDraftMessage{Peer: peer, Draft: draft}
if event.MaxID > 0 {
update.SetTopMsgID(event.MaxID)
}
return update
case domain.UpdateEventChannelViewForum:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil
}
return &tg.UpdateChannelViewForumAsMessages{ChannelID: event.Peer.ID, Enabled: event.Bool}
case domain.UpdateEventReadChannelDiscussionInbox:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil
}
return &tg.UpdateReadChannelDiscussionInbox{ChannelID: event.Peer.ID, TopMsgID: event.TopMsgID, ReadMaxID: event.MaxID}
case domain.UpdateEventReadChannelDiscussionOutbox:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil
}
return &tg.UpdateReadChannelDiscussionOutbox{ChannelID: event.Peer.ID, TopMsgID: event.TopMsgID, ReadMaxID: event.MaxID}
case domain.UpdateEventPeerSettings:
peer := tgPeer(event.Peer)
if peer == nil {
return nil
}
return &tg.UpdatePeerSettings{Peer: peer, Settings: tgPeerSettings(event.Settings)}
case domain.UpdateEventPeerStoryBlocked:
peer := tgPeer(event.Peer)
if peer == nil {
return nil
}
return &tg.UpdatePeerBlocked{PeerID: peer, Blocked: event.Bool, BlockedMyStoriesFrom: true}
case domain.UpdateEventDeleteMessages:
if len(event.MessageIDs) == 0 {
return nil
}
return &tg.UpdateDeleteMessages{
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventPinnedMessages:
peer := tgPeer(event.Peer)
if peer == nil || len(event.MessageIDs) == 0 {
return nil
}
return &tg.UpdatePinnedMessages{
Pinned: event.Bool,
Peer: peer,
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventReadHistoryOutbox:
return tgReadHistoryOutbox(event)
case domain.UpdateEventReadMessageContents:
if len(event.MessageIDs) == 0 {
return nil
}
contents := &tg.UpdateReadMessagesContents{
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
if event.Date > 0 {
// date 是内容被读取的时刻:客户端用它调度 TTL 媒体的
// "自读取起算"删除与 sender 侧已听时间。
contents.SetDate(event.Date)
}
return contents
case domain.UpdateEventEditMessage:
msg := tgMessage(event.Message)
if msg == nil {
return nil
}
return &tg.UpdateEditMessage{
Message: msg,
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventWebPage:
// 链接预览就地替换updateWebPage 仅携带已解析 webPage按 webPage id 与消息里的
// pending 占位关联),客户端就地换卡片、不触碰 edit_date。
if event.Message.Media == nil || event.Message.Media.WebPage == nil {
return nil
}
return &tg.UpdateWebPage{
Webpage: tgWebPage(*event.Message.Media.WebPage),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventMessagePoll:
if event.Message.ID <= 0 || event.Message.ID > domain.MaxMessageBoxID {
return nil
}
pollPeer := event.Message.Peer
if pollPeer.Type == "" || pollPeer.ID == 0 {
pollPeer = event.Peer
}
media := event.Message.Media
if media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil {
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 {
update.SetFilter(tgDialogFilter(*event.DialogFilter))
}
return update
case domain.UpdateEventDialogFilterOrder:
return &tg.UpdateDialogFilterOrder{Order: append([]int(nil), event.FilterOrder...)}
case domain.UpdateEventDialogFilters:
return &tg.UpdateDialogFilters{}
case domain.UpdateEventFolderPeers:
if len(event.FolderPeers) == 0 {
return nil
}
return &tg.UpdateFolderPeers{
FolderPeers: tgFolderPeers(event.FolderPeers),
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
}
}
func addChannels(out *tg.UpdatesDifference, seen map[int64]struct{}, viewerUserID int64, channels []domain.Channel) {
for _, ch := range channels {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
out.Chats = append(out.Chats, tgChannelChatMin(viewerUserID, ch))
}
}
func addChannelNudgeChat(out *tg.UpdatesDifference, seen map[int64]struct{}, chat tg.ChatClass) {
channelID, ok := channelChatID(chat)
if !ok || channelID == 0 {
return
}
if _, exists := seen[channelID]; exists {
upgradeMinChannelChat(out, channelID, chat)
return
}
seen[channelID] = struct{}{}
out.Chats = append(out.Chats, chat)
}
func upgradeMinChannelChat(out *tg.UpdatesDifference, channelID int64, candidate tg.ChatClass) {
next, ok := candidate.(*tg.Channel)
if !ok || next.Min {
return
}
for i, existing := range out.Chats {
existingID, ok := channelChatID(existing)
if !ok || existingID != channelID {
continue
}
if current, ok := existing.(*tg.Channel); ok && current.Min {
out.Chats[i] = candidate
}
return
}
}
func channelChatID(chat tg.ChatClass) (int64, bool) {
switch v := chat.(type) {
case *tg.Channel:
return v.ID, true
case *tg.ChannelForbidden:
return v.ID, true
default:
return 0, false
}
}
func tgChannelParticipantForUpdate(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass {
if member.UserID == 0 {
return nil
}
if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages {
return &tg.ChannelParticipantBanned{
Left: member.Status != domain.ChannelMemberActive,
Peer: &tg.PeerUser{UserID: member.UserID},
KickedBy: member.InviterUserID,
Date: member.LeftAt,
BannedRights: tgChatBannedRights(member.BannedRights),
}
}
if member.Status == domain.ChannelMemberLeft {
return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}}
}
return tgChannelParticipant(selfUserID, member)
}
func tgLangPackDifference(pack domain.LangPack) *tg.LangPackDifference {
return &tg.LangPackDifference{
LangCode: pack.LangCode,
FromVersion: pack.FromVersion,
Version: pack.Version,
Strings: tgLangPackStrings(pack.Strings),
}
}

View file

@ -0,0 +1,65 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// TestDifferenceNewMessageKeepsPinServiceReply 验证差分输出的 pin 服务
// 消息保留 reply 头reply_to_msg_id = 接收方视角被置顶消息 id缺失
// 时 TDesktop 渲染 "pinned Deleted message"。
func TestDifferenceNewMessageKeepsPinServiceReply(t *testing.T) {
const bobID = int64(1000000042)
const aliceID = int64(1000000041)
event := domain.UpdateEvent{
UserID: bobID,
Type: domain.UpdateEventNewMessage,
Pts: 351,
PtsCount: 1,
Date: 1700000900,
Message: domain.Message{
ID: 117,
OwnerUserID: bobID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
Date: 1700000900,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{Kind: domain.MessageServiceActionPinMessage},
},
ReplyTo: &domain.MessageReply{
MessageID: 115,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
},
},
}
diff := domain.UpdateDifference{
State: domain.UpdateState{Pts: 351, Date: 1700000901},
Events: []domain.UpdateEvent{event},
}
out, ok := tgUpdatesDifference(bobID, diff).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference type = %T, want *tg.UpdatesDifference", tgUpdatesDifference(bobID, diff))
}
if len(out.NewMessages) != 1 {
t.Fatalf("new messages = %d, want 1", len(out.NewMessages))
}
svc, ok := out.NewMessages[0].(*tg.MessageService)
if !ok {
t.Fatalf("message type = %T, want messageService", out.NewMessages[0])
}
if _, ok := svc.Action.(*tg.MessageActionPinMessage); !ok {
t.Fatalf("action = %T, want messageActionPinMessage", svc.Action)
}
reply, ok := svc.GetReplyTo()
if !ok {
t.Fatalf("difference 服务消息丢失 reply 头TDesktop 将渲染 pinned Deleted message")
}
header, ok := reply.(*tg.MessageReplyHeader)
if !ok || header.ReplyToMsgID != 115 {
t.Fatalf("reply header = %+v, want reply_to_msg_id=115", reply)
}
}

View file

@ -0,0 +1,308 @@
package rpc
import (
"time"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// tgSelfUser 把 domain.User 转为 self 标记的 tg.Useroptional 字段由 Encode 自动 SetFlags
func tgSelfUser(u domain.User) *tg.User {
out := &tg.User{
ID: u.ID,
AccessHash: u.AccessHash,
FirstName: u.FirstName,
LastName: u.LastName,
Username: u.Username,
Phone: u.Phone,
Self: true,
Verified: u.Verified,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
applyTgUserColorFields(out, u)
if photo := tgUserProfilePhoto(u); photo != nil {
out.Photo = photo
}
return out
}
func tgUser(u domain.User) *tg.User {
out := &tg.User{
ID: u.ID,
AccessHash: u.AccessHash,
FirstName: u.FirstName,
LastName: u.LastName,
Username: u.Username,
Phone: u.Phone,
Verified: u.Verified,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,
CloseFriend: u.CloseFriend,
Usernames: tgUsernames(u.Username),
}
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
applyTgUserColorFields(out, u)
if photo := tgUserProfilePhoto(u); photo != nil {
out.Photo = photo
}
return out
}
// applyTgUserPremiumFields 由到期时间即时派生 premium flagbit28独立位
// emoji status。判断用真实时钟premium 的权威来源是 premium_expires_at 本身,
// 到期即停发,正确性不依赖后台 sweeper它只负责清理与 updateUser 通知);
// bot 永不带 premiumPremiumActiveAt 内排除)。
func applyTgUserPremiumFields(out *tg.User, u domain.User) {
now := time.Now().Unix()
if !u.PremiumActiveAt(now) {
return
}
out.Premium = true
if u.EmojiStatusActiveAt(now) {
out.SetEmojiStatus(tgUserEmojiStatus(u, now))
}
}
// tgUserEmojiStatus 把用户 emoji status 转为 TL未设置/已失效返回
// emojiStatusEmptyupdateUserEmojiStatus 清除语义用。user TL 内联输出仍由
// applyTgUserPremiumFields 控制(无状态时直接省略字段,对齐官方 user 编码)。
func tgUserEmojiStatus(u domain.User, now int64) tg.EmojiStatusClass {
if !u.EmojiStatusActiveAt(now) {
return &tg.EmojiStatusEmpty{}
}
status := &tg.EmojiStatus{DocumentID: u.EmojiStatusDocumentID}
if u.EmojiStatusUntil > 0 {
status.SetUntil(u.EmojiStatusUntil)
}
return status
}
func applyTgUserColorFields(out *tg.User, u domain.User) {
if color := tgUserPeerColor(u.Color); color != nil {
out.SetColor(color)
}
if color := tgUserPeerColor(u.ProfileColor); color != nil {
out.SetProfileColor(color)
}
}
func tgUserPeerColor(color domain.PeerColor) tg.PeerColorClass {
if color.Empty() {
return nil
}
out := &tg.PeerColor{}
if color.HasColor {
out.SetColor(color.Color)
}
if color.BackgroundEmojiID != 0 {
out.SetBackgroundEmojiID(color.BackgroundEmojiID)
}
return out
}
// applyTgUserBotFields 应用 bot/普通用户的互斥输出:
// - bot必须同时置 bot flag 与 bot_info_versionLayer 225 两者共用 flags
// bit14TDesktop 只认 bot_info_version 字段是否存在);无 phone、无 status
// (客户端显示 "bot" 而非 last seen
// - 普通用户:照常输出 presence status。
func applyTgUserBotFields(out *tg.User, u domain.User) {
if !u.Bot {
out.Status = tgUserStatus(u.Status)
return
}
out.Bot = true
version := u.BotInfoVersion
if version < 1 {
version = 1
}
out.SetBotInfoVersion(version)
if u.ID != domain.BotFatherUserID {
out.SetBotBusiness(true)
}
out.Phone = ""
}
// isSystemUserID 报告 id 是否为内置系统账号777000 / BotFather
func isSystemUserID(id int64) bool {
_, ok := domain.SystemUserByID(id)
return ok
}
// tgUserProfilePhoto 由 domain.User 反范式头像字段构造 UserProfilePhoto无头像返回 nilEncode 时为 empty
func tgUserProfilePhoto(u domain.User) tg.UserProfilePhotoClass {
if u.PhotoID == 0 {
return nil
}
photo := &tg.UserProfilePhoto{PhotoID: u.PhotoID, DCID: u.PhotoDCID, Personal: u.PhotoPersonal}
if u.PhotoHasVideo {
photo.SetHasVideo(true)
}
if len(u.PhotoStripped) > 0 {
photo.SetStrippedThumb(u.PhotoStripped)
}
return photo
}
func tgUserStatus(status domain.UserStatus) tg.UserStatusClass {
switch status.Kind {
case domain.UserStatusOnline:
if status.Expires > 0 {
return &tg.UserStatusOnline{Expires: status.Expires}
}
case domain.UserStatusOffline:
if status.WasOnline > 0 {
return &tg.UserStatusOffline{WasOnline: status.WasOnline}
}
case domain.UserStatusLastWeek:
return &tg.UserStatusLastWeek{}
case domain.UserStatusLastMonth:
return &tg.UserStatusLastMonth{}
case domain.UserStatusEmpty:
return &tg.UserStatusEmpty{}
case domain.UserStatusRecently, domain.UserStatusUnknown:
return &tg.UserStatusRecently{}
}
return &tg.UserStatusRecently{}
}
func tgUsernames(username string) []tg.Username {
if username == "" {
return nil
}
return []tg.Username{{Editable: true, Active: true, Username: username}}
}
func tgContacts(list domain.ContactList) tg.ContactsContactsClass {
out := &tg.ContactsContacts{
Contacts: make([]tg.Contact, 0, len(list.Contacts)),
Users: make([]tg.UserClass, 0, len(list.Contacts)),
SavedCount: len(list.Contacts),
}
for _, c := range list.Contacts {
out.Contacts = append(out.Contacts, tg.Contact{UserID: c.User.ID, Mutual: c.Mutual})
out.Users = append(out.Users, tgUser(c.User))
}
return out
}
func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound {
out := &tg.ContactsFound{
MyResults: make([]tg.PeerClass, 0, len(res.MyResults)+len(res.MyChannelResults)),
Results: make([]tg.PeerClass, 0, len(res.Results)+len(res.ChannelResults)),
Chats: make([]tg.ChatClass, 0, len(res.MyChannelResults)+len(res.ChannelResults)),
Users: make([]tg.UserClass, 0, len(res.MyResults)+len(res.Results)),
}
seen := make(map[int64]struct{}, len(res.MyResults)+len(res.Results))
appendUser := func(u domain.User) {
if u.ID == 0 {
return
}
if _, ok := seen[u.ID]; ok {
return
}
seen[u.ID] = struct{}{}
out.Users = append(out.Users, tgUser(u))
}
seenChannels := make(map[int64]struct{}, len(res.MyChannelResults)+len(res.ChannelResults))
appendChannel := func(ch domain.Channel, self *domain.ChannelMember) {
if ch.ID == 0 {
return
}
if _, ok := seenChannels[ch.ID]; ok {
return
}
seenChannels[ch.ID] = struct{}{}
out.Chats = append(out.Chats, tgChannelChat(viewerUserID, ch, self))
}
for _, u := range res.MyResults {
out.MyResults = append(out.MyResults, &tg.PeerUser{UserID: u.ID})
appendUser(u)
}
for _, ch := range res.MyChannelResults {
out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID})
appendChannel(ch, nil)
}
for _, u := range res.Results {
out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID})
appendUser(u)
}
for _, ch := range res.ChannelResults {
out.Results = append(out.Results, &tg.PeerChannel{ChannelID: ch.ID})
appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberLeft})
}
return out
}
func tgUsers(users []domain.User) []tg.UserClass {
out := make([]tg.UserClass, 0, len(users))
for _, u := range users {
out = append(out, tgUser(u))
}
return out
}
func tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass {
out := make([]tg.UserClass, 0, len(users))
for _, u := range users {
if viewerUserID != 0 && u.ID == viewerUserID {
out = append(out, tgSelfUser(u))
continue
}
out = append(out, tgUser(u))
}
return out
}
func addUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, viewerUserID int64, users []domain.User) {
for _, u := range users {
if u.ID == 0 {
continue
}
if _, ok := seen[u.ID]; ok {
continue
}
seen[u.ID] = struct{}{}
// difference 里自己的 user 必须带 self 标志:客户端会用该对象
// 持久覆盖当前账号资料self=false 会破坏 Saved Messages 语义。
if viewerUserID != 0 && u.ID == viewerUserID {
out.Users = append(out.Users, tgSelfUser(u))
continue
}
out.Users = append(out.Users, tgUser(u))
}
}
func addMessageUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, msg domain.Message) {
for _, peer := range []domain.Peer{msg.From, msg.Peer, domain.Peer{Type: domain.PeerTypeUser, ID: msg.ViaBotID}} {
if peer.Type != domain.PeerTypeUser {
continue
}
if _, ok := seen[peer.ID]; ok {
continue
}
seen[peer.ID] = struct{}{}
if u, ok := domain.SystemUserByID(peer.ID); ok {
out.Users = append(out.Users, tgUser(u))
}
}
}
func tgChannelEmojiStatus(status domain.ChannelEmojiStatus) tg.EmojiStatusClass {
if status.Empty() {
return nil
}
out := &tg.EmojiStatus{DocumentID: status.DocumentID}
if status.Until > 0 {
out.SetUntil(status.Until)
}
return out
}

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