chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
File diff suppressed because it is too large
Load diff
566
internal/rpc/account_business.go
Normal file
566
internal/rpc/account_business.go
Normal 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)
|
||||
}
|
||||
}
|
||||
147
internal/rpc/account_color_rpc_test.go
Normal file
147
internal/rpc/account_color_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
313
internal/rpc/account_notify.go
Normal file
313
internal/rpc/account_notify.go
Normal 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_previews);compare_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,而非逐个 GetChannel(N+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)
|
||||
}
|
||||
289
internal/rpc/account_notify_rpc_test.go
Normal file
289
internal/rpc/account_notify_rpc_test.go
Normal 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-1:dialog 投影从 per-user notify
|
||||
// 缓存读取——重复 getDialogs 只加载一次(命中 0 PG),update/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
|
||||
}
|
||||
120
internal/rpc/account_profile_emoji_test.go
Normal file
120
internal/rpc/account_profile_emoji_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
57
internal/rpc/account_settings_cache.go
Normal file
57
internal/rpc/account_settings_cache.go
Normal 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)
|
||||
})
|
||||
}
|
||||
138
internal/rpc/account_settings_rpc_test.go
Normal file
138
internal/rpc/account_settings_rpc_test.go
Normal 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 此前是硬编码回显 stub(set 不持久化)。本测试验证
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
265
internal/rpc/account_themes.go
Normal file
265
internal/rpc/account_themes.go
Normal 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
|
||||
}
|
||||
183
internal/rpc/account_themes_compat.go
Normal file
183
internal/rpc/account_themes_compat.go
Normal 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)
|
||||
}
|
||||
90
internal/rpc/account_themes_compat_test.go
Normal file
90
internal/rpc/account_themes_compat_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
264
internal/rpc/account_themes_rpc_test.go
Normal file
264
internal/rpc/account_themes_rpc_test.go
Normal 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)
|
||||
}
|
||||
33
internal/rpc/admin_hooks.go
Normal file
33
internal/rpc/admin_hooks.go
Normal 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
|
||||
}
|
||||
284
internal/rpc/android_compat_test.go
Normal file
284
internal/rpc/android_compat_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.importBotAuthorization:bot 程序凭 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 登出不广播 offline(bot 无 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 清 0,TCP 真正断开时 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
59
internal/rpc/auth_gate.go
Normal 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,
|
||||
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
||||
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 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
|
||||
}
|
||||
}
|
||||
103
internal/rpc/auth_password_pending_test.go
Normal file
103
internal/rpc/auth_password_pending_test.go
Normal 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
|
||||
}
|
||||
196
internal/rpc/auth_qr_login_test.go
Normal file
196
internal/rpc/auth_qr_login_test.go
Normal 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
389
internal/rpc/bots.go
Normal 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/getBotInfo:owner 经 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)
|
||||
// P3:startBot 深链 + 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_id(bot-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{}
|
||||
}
|
||||
}
|
||||
123
internal/rpc/bots_callback.go
Normal file
123
internal/rpc/bots_callback.go
Normal 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.game)P3 不支持:返回空答案(客户端不弹任何东西),
|
||||
// 不挂起、不推送(避免给 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):仅在线推给
|
||||
// bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。
|
||||
// MsgID 透传请求者侧的 box id(P3 不做 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.botUserID,I6)。
|
||||
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
|
||||
}
|
||||
89
internal/rpc/bots_errors.go
Normal file
89
internal/rpc/bots_errors.go
Normal 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()
|
||||
}
|
||||
267
internal/rpc/bots_group_rpc_test.go
Normal file
267
internal/rpc/bots_group_rpc_test.go
Normal 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
102
internal/rpc/bots_hooks.go
Normal 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 且在线」的用户推
|
||||
// updateBotCommands(peer = 该 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
1200
internal/rpc/bots_inline.go
Normal file
File diff suppressed because it is too large
Load diff
191
internal/rpc/bots_inline_message_id.go
Normal file
191
internal/rpc/bots_inline_message_id.go
Normal 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()),
|
||||
})
|
||||
}
|
||||
2346
internal/rpc/bots_inline_rpc_test.go
Normal file
2346
internal/rpc/bots_inline_rpc_test.go
Normal file
File diff suppressed because it is too large
Load diff
862
internal/rpc/bots_longtail.go
Normal file
862
internal/rpc/bots_longtail.go
Normal 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()
|
||||
}
|
||||
316
internal/rpc/bots_longtail_rpc_test.go
Normal file
316
internal/rpc/bots_longtail_rpc_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
142
internal/rpc/bots_rpc_test.go
Normal file
142
internal/rpc/bots_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
64
internal/rpc/bots_startbot.go
Normal file
64
internal/rpc/bots_startbot.go
Normal 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,返回真实 Updates(I7)。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
|
||||
}
|
||||
127
internal/rpc/callback_registry.go
Normal file
127
internal/rpc/callback_registry.go
Normal 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 把答案投递到一个等待者已离开的 ch(TOCTOU)。
|
||||
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
|
||||
// 必须等于注册时的 botUserID,I6)。返回是否成功投递(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
|
||||
}
|
||||
77
internal/rpc/callback_registry_test.go
Normal file
77
internal/rpc/callback_registry_test.go
Normal 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)
|
||||
// 非属主 bot(999)不得解挂(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)
|
||||
}
|
||||
}
|
||||
46
internal/rpc/channel_can_view_participants_test.go
Normal file
46
internal/rpc/channel_can_view_participants_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
173
internal/rpc/channel_fanout_classifier_test.go
Normal file
173
internal/rpc/channel_fanout_classifier_test.go
Normal 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 worker(participant / state / TTL-set / forum-pin / read-outbox /
|
||||
// group-call version / typing);viewer-only 无 durable event(poll / 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 UpdateChannel;broadcast 仅 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
409
internal/rpc/channel_fanout_dispatcher.go
Normal file
409
internal/rpc/channel_fanout_dispatcher.go
Normal 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_events,worker 只做在线投递。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(新消息/编辑/删除/pin);reaction/poll(viewer-only 零 pts)、participant/TTL/
|
||||
// channel state(无 channel pts)、typing(transient)不走此路径(设计 §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 的真实 payload(members 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 投影一次性算好预热进共享 cache(O(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 投影一次性算好预热进共享 cache(O(owner))。注意 edit 不做 per-viewer mention
|
||||
// overlay(EditChannelMessageResult 不带 MentionUserIDs,编辑新增 @ 走 durable channel_unread_mentions
|
||||
// 经 getChannelDifference 自愈),故各 viewer 的 mentioned/media_unread 字节恒等,预热不影响等价。
|
||||
//
|
||||
// nudge pts 取两容器较大值:edit 可产两条带 pts 事件(Event=编辑本身、ServiceEvent=如 todo 完成的
|
||||
// "X completed Y" 服务消息),ServiceEvent.Pts 在后分配恒更大;某些编辑只产 ServiceEvent(Event.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 内含多条 UpdateNewChannelMessage),peer 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 被
|
||||
// 消费后客户端会 getChannelDifference(DrKLO 对未加载频道还会先 getPeerDialogs,设计 §10.3),
|
||||
// 大群高频下可能放大。可经 Config.ChannelNudgeMaxTargets 覆盖;客户端侧由 difference/getPeerDialogs
|
||||
// 的 FLOOD_WAIT(Phase 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 必须带 pts(flags&1)——DrKLO 对不带 pts 的 tooLong 不触发
|
||||
// getChannelDifference(设计 §10.3)。走 pushUserUpdates(best-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,
|
||||
})
|
||||
}
|
||||
}
|
||||
319
internal/rpc/channel_fanout_dispatcher_test.go
Normal file
319
internal/rpc/channel_fanout_dispatcher_test.go
Normal 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
|
||||
}
|
||||
|
||||
// TestChannelFanoutDispatcherSyncFallback:dispatcher 未启动时 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelFanoutDispatcherDeliversAsync:dispatcher 启动后 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelFanoutDispatcherInvokesPrefetch:worker 在逐 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelEditMessageFanoutOwnerIDsGating:owner-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
|
||||
}
|
||||
|
||||
// TestChannelEditMessageFanoutInvokesPrefetch:enqueueChannelEditMessageFanout 在逐 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]
|
||||
}
|
||||
|
||||
// TestChannelFanoutDispatcherNudgesBeyondCapMembers(P0-8):完整 payload 投递给 cap 内
|
||||
// recipients 后,cap 外在线成员收到带 pts 的 UpdateChannelTooLong nudge;cap 内成员不重复 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 的 UpdateChannelTooLong(DrKLO 对不带 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelEditMessageFanoutNudgePtsUsesMaxContainer:edit 可只产服务消息容器(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)
|
||||
|
||||
// 仅服务消息容器有 pts:Event.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)
|
||||
}
|
||||
}
|
||||
81
internal/rpc/channel_fanout_privacy_bot_rpc_test.go
Normal file
81
internal/rpc/channel_fanout_privacy_bot_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
162
internal/rpc/channel_full_bot_cache.go
Normal file
162
internal/rpc/channel_full_bot_cache.go
Normal 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
57
internal/rpc/channels_admin_log_users_rpc_test.go
Normal file
57
internal/rpc/channels_admin_log_users_rpc_test.go
Normal 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
|
||||
}
|
||||
21
internal/rpc/channels_constants.go
Normal file
21
internal/rpc/channels_constants.go
Normal 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
|
||||
)
|
||||
278
internal/rpc/channels_core.go
Normal file
278
internal/rpc/channels_core.go
Normal 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 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 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...)
|
||||
}
|
||||
147
internal/rpc/channels_delete_monoforum_test.go
Normal file
147
internal/rpc/channels_delete_monoforum_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
internal/rpc/channels_dialogs.go
Normal file
75
internal/rpc/channels_dialogs.go
Normal 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
|
||||
}
|
||||
605
internal/rpc/channels_dialogs_rpc_test.go
Normal file
605
internal/rpc/channels_dialogs_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
148
internal/rpc/channels_invite_errors_rpc_test.go
Normal file
148
internal/rpc/channels_invite_errors_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
447
internal/rpc/channels_invites.go
Normal file
447
internal/rpc/channels_invites.go
Normal 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 227:messages.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)
|
||||
}
|
||||
}
|
||||
856
internal/rpc/channels_invites_members_rpc_test.go
Normal file
856
internal/rpc/channels_invites_members_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
67
internal/rpc/channels_join_dialog_rpc_test.go
Normal file
67
internal/rpc/channels_join_dialog_rpc_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
132
internal/rpc/channels_leave_rpc_test.go
Normal file
132
internal/rpc/channels_leave_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
601
internal/rpc/channels_legacy_chat.go
Normal file
601
internal/rpc/channels_legacy_chat.go
Normal 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,
|
||||
}
|
||||
}
|
||||
176
internal/rpc/channels_legacy_settings_rpc_test.go
Normal file
176
internal/rpc/channels_legacy_settings_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
401
internal/rpc/channels_member_rank_rpc_test.go
Normal file
401
internal/rpc/channels_member_rank_rpc_test.go
Normal 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 覆盖成员 Tag(rank-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 提拔 other(promoted_by=tagger)后可改其 tag;creator 重新提拔后不可。
|
||||
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_FORBIDDEN;admin/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 log:rank 编辑必须记 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")
|
||||
}
|
||||
|
||||
// 重进是全新 participant:admin(带 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_INVALID,broadcast 的 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)
|
||||
}
|
||||
}
|
||||
830
internal/rpc/channels_members.go
Normal file
830
internal/rpc/channels_members.go
Normal 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 227:channels.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)
|
||||
}
|
||||
}
|
||||
408
internal/rpc/channels_messages.go
Normal file
408
internal/rpc/channels_messages.go
Normal 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、不取 ctx),async 无竞态;同 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 返回 Updates,TL 层没有 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
|
||||
}
|
||||
937
internal/rpc/channels_messages_rpc_test.go
Normal file
937
internal/rpc/channels_messages_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
229
internal/rpc/channels_multi_pin_android_rpc_test.go
Normal file
229
internal/rpc/channels_multi_pin_android_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 点置顶栏跳最旧 pin:scrollToMessageId → 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 构造器 #93d7b347(id: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])
|
||||
}
|
||||
}
|
||||
1278
internal/rpc/channels_passive_stubs_rpc_test.go
Normal file
1278
internal/rpc/channels_passive_stubs_rpc_test.go
Normal file
File diff suppressed because it is too large
Load diff
199
internal/rpc/channels_public_preview_rpc_test.go
Normal file
199
internal/rpc/channels_public_preview_rpc_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
130
internal/rpc/channels_read_reactions.go
Normal file
130
internal/rpc/channels_read_reactions.go
Normal 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
|
||||
}
|
||||
101
internal/rpc/channels_register.go
Normal file
101
internal/rpc/channels_register.go
Normal 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)
|
||||
}
|
||||
245
internal/rpc/channels_send_as_foreign_rpc_test.go
Normal file
245
internal/rpc/channels_send_as_foreign_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
473
internal/rpc/channels_settings.go
Normal file
473
internal/rpc/channels_settings.go
Normal 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 epic):channelTitleUpdates 同时含①无 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)
|
||||
}
|
||||
}
|
||||
1530
internal/rpc/channels_settings_rpc_test.go
Normal file
1530
internal/rpc/channels_settings_rpc_test.go
Normal file
File diff suppressed because it is too large
Load diff
84
internal/rpc/channels_state_mutation.go
Normal file
84
internal/rpc/channels_state_mutation.go
Normal 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
|
||||
}
|
||||
715
internal/rpc/channels_stubs.go
Normal file
715
internal/rpc/channels_stubs.go
Normal 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)
|
||||
}
|
||||
114
internal/rpc/channels_topics.go
Normal file
114
internal/rpc/channels_topics.go
Normal 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,
|
||||
}
|
||||
}
|
||||
404
internal/rpc/channels_updates.go
Normal file
404
internal/rpc/channels_updates.go
Normal 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.3):nudge 被消费后客户端会触发
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
748
internal/rpc/channels_updates_rpc_test.go
Normal file
748
internal/rpc/channels_updates_rpc_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
263
internal/rpc/chat_automation_rpc_test.go
Normal file
263
internal/rpc/chat_automation_rpc_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
17
internal/rpc/contacts_users_projection_test.go
Normal file
17
internal/rpc/contacts_users_projection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1298
internal/rpc/contacts_users_rpc_test.go
Normal file
1298
internal/rpc/contacts_users_rpc_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 不接收主动 updates(media/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
77
internal/rpc/convert_auth.go
Normal file
77
internal/rpc/convert_auth.go
Normal 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
|
||||
}
|
||||
460
internal/rpc/convert_business.go
Normal file
460
internal/rpc/convert_business.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
850
internal/rpc/convert_channels_core.go
Normal file
850
internal/rpc/convert_channels_core.go
Normal 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 计数器(初始 1);signatures 开启时附作者签名。
|
||||
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 flag(Android 对 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=false,Profile 的 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)
|
||||
}
|
||||
// 付费 reaction(Stars)是广播频道默认能力——官方语义下 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{}
|
||||
}
|
||||
69
internal/rpc/convert_channels_core_test.go
Normal file
69
internal/rpc/convert_channels_core_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
113
internal/rpc/convert_channels_monoforum_test.go
Normal file
113
internal/rpc/convert_channels_monoforum_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
329
internal/rpc/convert_dialogs.go
Normal file
329
internal/rpc/convert_dialogs.go
Normal 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_pinned,archive 行只能从 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
|
||||
}
|
||||
116
internal/rpc/convert_encrypted.go
Normal file
116
internal/rpc/convert_encrypted.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 密聊(Secret Chat)domain ↔ 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_a(TL 注释钉死)。
|
||||
// - pending 态:admin 看 encryptedChatWaiting(无 g_a),participant 看
|
||||
// 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 {
|
||||
// GAOrB:admin 看 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_a),participant 视角 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
|
||||
}
|
||||
98
internal/rpc/convert_encrypted_test.go
Normal file
98
internal/rpc/convert_encrypted_test.go
Normal 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_a),access_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_a),access_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)
|
||||
}
|
||||
}
|
||||
125
internal/rpc/convert_markup.go
Normal file
125
internal/rpc/convert_markup.go
Normal 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-reply:P3 不支持,丢弃。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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: // pending:webPagePending{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:
|
||||
|
|
|
|||
167
internal/rpc/convert_media_webpage_test.go
Normal file
167
internal/rpc/convert_media_webpage_test.go
Normal 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 媒体 codec(json.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)
|
||||
}
|
||||
}
|
||||
570
internal/rpc/convert_messages.go
Normal file
570
internal/rpc/convert_messages.go
Normal 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_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。
|
||||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
// rich_message(Layer 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 回复(评论)投影为独立的 messageReplyStoryHeader(peer=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 reactors(My/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 携带 InputUser,inputUserSelf 解析为
|
||||
// viewerUserID(viewer 未知时该实体丢弃而不是落成 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
|
||||
}
|
||||
195
internal/rpc/convert_misc.go
Normal file
195
internal/rpc/convert_misc.go
Normal 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: ""}
|
||||
}
|
||||
}
|
||||
82
internal/rpc/convert_passkey.go
Normal file
82
internal/rpc/convert_passkey.go
Normal 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
|
||||
}
|
||||
190
internal/rpc/convert_phone.go
Normal file
190
internal/rpc/convert_phone.go
Normal 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。
|
||||
// 永远返回非 nil(gotd 对 nil vector 编码与空 vector 相同,但保持显式)。
|
||||
// 不下发 legacy phoneConnection(Telegram 自有 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 构造「被叫其它设备停振铃」的合成终态。
|
||||
//
|
||||
// 硬契约(勿改回推 phoneCallAccepted):TDesktop 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
|
||||
}
|
||||
}
|
||||
339
internal/rpc/convert_phone_group.go
Normal file
339
internal/rpc/convert_phone_group.go
Normal 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_video:TDesktop 不读;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 按位转 int32(join JSON 同款语义)
|
||||
}
|
||||
if p.Left {
|
||||
// left 行不再携带 can_self_unmute 语义。
|
||||
out.CanSelfUnmute = false
|
||||
} else {
|
||||
// video_joined:RTC 入会一律置位。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:屏幕实例自己的音频 ssrc(int32 位重解释存原值)。
|
||||
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 groupCallParticipantVideo(Active 才输出)。
|
||||
// withAudioSource 仅 presentation 置 true(DrKLO 用 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)
|
||||
}
|
||||
|
||||
// ---- 下行 JSON(updateGroupCallConnection.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
|
||||
}
|
||||
336
internal/rpc/convert_polls.go
Normal file
336
internal/rpc/convert_polls.go
Normal 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)不含机密;机密只进 PollDefinition(polls 权威表)。
|
||||
|
||||
// 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 无 option(TDesktop 创建路径)——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_answers(Layer 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.Poll(Closed/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 视角结果;未 enrich(Results==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 组装一条 updateMessagePoll(poll 总是内联,避免客户端缓存缺失)。
|
||||
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
|
||||
}
|
||||
134
internal/rpc/convert_rich_message.go
Normal file
134
internal/rpc/convert_rich_message.go
Normal 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:仅支持 inputRichMessage(blocks 形态);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 1:HTML/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
|
||||
}
|
||||
439
internal/rpc/convert_stories.go
Normal file
439
internal/rpc/convert_stories.go
Normal 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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
220
internal/rpc/convert_theme.go
Normal file
220
internal/rpc/convert_theme.go
Normal 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
|
||||
}
|
||||
595
internal/rpc/convert_updates.go
Normal file
595
internal/rpc/convert_updates.go
Normal 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 flag(TDesktop 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 零值不编码 flag(EncodeBare 自动 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),
|
||||
}
|
||||
}
|
||||
65
internal/rpc/convert_updates_pin_service_test.go
Normal file
65
internal/rpc/convert_updates_pin_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
308
internal/rpc/convert_users.go
Normal file
308
internal/rpc/convert_users.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 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 flag(bit28,独立位)与
|
||||
// emoji status。判断用真实时钟:premium 的权威来源是 premium_expires_at 本身,
|
||||
// 到期即停发,正确性不依赖后台 sweeper(它只负责清理与 updateUser 通知);
|
||||
// bot 永不带 premium(PremiumActiveAt 内排除)。
|
||||
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:未设置/已失效返回
|
||||
// emojiStatusEmpty(updateUserEmojiStatus 清除语义用)。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_version(Layer 225 两者共用 flags
|
||||
// bit14,TDesktop 只认 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;无头像返回 nil(Encode 时为 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
Loading…
Add table
Add a link
Reference in a new issue