Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

292
internal/rpc/account.go Normal file
View file

@ -0,0 +1,292 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tg.ServerDispatcher) {
d.OnAccountCheckUsername(r.onAccountCheckUsername)
d.OnAccountUpdateProfile(r.onAccountUpdateProfile)
d.OnAccountUpdateUsername(r.onAccountUpdateUsername)
d.OnAccountGetPassword(func(ctx context.Context) (*tg.AccountPassword, error) {
if r.deps.Account == nil {
return tgPassword(domain.PasswordSettings{SecureRandom: []byte("telesrv-tdesktop-dev-secure-rand")}), nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
settings, err := r.deps.Account.GetPassword(ctx, userID)
if err != nil {
return nil, internalErr()
}
return tgPassword(settings), nil
})
d.OnAccountGetNotifySettings(func(ctx context.Context, peer tg.InputNotifyPeerClass) (*tg.PeerNotifySettings, error) {
return tdesktop.NotifySettings(), nil
})
d.OnAccountUpdateNotifySettings(func(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) {
return true, nil
})
d.OnAccountGetPrivacy(func(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) {
return tdesktop.PrivacyRules(key), nil
})
d.OnAccountGetAuthorizations(func(ctx context.Context) (*tg.AccountAuthorizations, error) {
return tdesktop.Authorizations(), nil
})
d.OnAccountGetDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
return tdesktop.DefaultEmojiStatuses(), nil
})
d.OnAccountGetCollectibleEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
return tdesktop.CollectibleEmojiStatuses(), nil
})
d.OnAccountGetDefaultGroupPhotoEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) {
return tdesktop.DefaultGroupPhotoEmojis(), nil
})
d.OnAccountGetConnectedBots(func(ctx context.Context) (*tg.AccountConnectedBots, error) {
return tdesktop.ConnectedBots(), nil
})
d.OnAccountGetReactionsNotifySettings(r.onAccountGetReactionsNotifySettings)
d.OnAccountSetReactionsNotifySettings(r.onAccountSetReactionsNotifySettings)
d.OnAccountGetContactSignUpNotification(func(ctx context.Context) (bool, error) {
return false, nil
})
d.OnAccountGetThemes(func(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) {
return tdesktop.AccountThemes(), nil
})
d.OnAccountGetContentSettings(func(ctx context.Context) (*tg.AccountContentSettings, error) {
return tdesktop.ContentSettings(), nil
})
d.OnAccountGetGlobalPrivacySettings(func(ctx context.Context) (*tg.GlobalPrivacySettings, error) {
return tdesktop.GlobalPrivacySettings(), nil
})
d.OnAccountGetPasskeys(func(ctx context.Context) (*tg.AccountPasskeys, error) {
return tdesktop.Passkeys(), nil
})
d.OnAccountGetSavedMusicIDs(func(ctx context.Context, hash int64) (tg.AccountSavedMusicIDsClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return &tg.AccountSavedMusicIDs{IDs: []int64{}}, nil
})
d.OnAccountGetAccountTTL(r.onAccountGetAccountTTL)
d.OnAccountUpdateStatus(r.onAccountUpdateStatus)
}
func (r *Router) onAccountGetAccountTTL(ctx context.Context) (*tg.AccountDaysTTL, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return &tg.AccountDaysTTL{Days: 365}, nil
}
func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !authorized || userID == 0 {
return true, nil
}
status := r.setPresenceFromContext(ctx, userID, offline)
r.pushUserStatus(ctx, userID, status)
return true, nil
}
type accountReactionSettingsService interface {
GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error)
SetReactionsNotifySettings(ctx context.Context, userID int64, settings domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error)
}
func (r *Router) onAccountGetReactionsNotifySettings(ctx context.Context) (*tg.ReactionsNotifySettings, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if svc, ok := r.deps.Account.(accountReactionSettingsService); ok {
settings, err := svc.GetReactionSettings(ctx, userID)
if err != nil {
return nil, internalErr()
}
return tgReactionsNotifySettings(settings.Notify), nil
}
return tgReactionsNotifySettings(domain.DefaultAccountReactionSettings().Notify), nil
}
func (r *Router) onAccountSetReactionsNotifySettings(ctx context.Context, settings tg.ReactionsNotifySettings) (*tg.ReactionsNotifySettings, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
notify := domainReactionsNotifySettings(settings)
if svc, ok := r.deps.Account.(accountReactionSettingsService); ok {
next, err := svc.SetReactionsNotifySettings(ctx, userID, notify)
if err != nil {
return nil, internalErr()
}
return tgReactionsNotifySettings(next.Notify), nil
}
return tgReactionsNotifySettings(notify), nil
}
func domainReactionsNotifySettings(settings tg.ReactionsNotifySettings) domain.ReactionsNotifySettings {
return domain.ReactionsNotifySettings{
MessagesFrom: domainReactionNotifyFrom(settings.GetMessagesNotifyFrom),
StoriesFrom: domainReactionNotifyFrom(settings.GetStoriesNotifyFrom),
PollVotesFrom: domainReactionNotifyFrom(settings.GetPollVotesNotifyFrom),
ShowPreviews: settings.ShowPreviews,
}
}
func domainReactionNotifyFrom(get func() (tg.ReactionNotificationsFromClass, bool)) domain.ReactionNotifyFrom {
if get == nil {
return domain.ReactionNotifyFromNone
}
value, ok := get()
if !ok || value == nil {
return domain.ReactionNotifyFromNone
}
switch value.(type) {
case *tg.ReactionNotificationsFromAll:
return domain.ReactionNotifyFromAll
case *tg.ReactionNotificationsFromContacts:
return domain.ReactionNotifyFromContacts
default:
return domain.ReactionNotifyFromNone
}
}
func tgReactionsNotifySettings(settings domain.ReactionsNotifySettings) *tg.ReactionsNotifySettings {
out := &tg.ReactionsNotifySettings{
Sound: &tg.NotificationSoundDefault{},
ShowPreviews: settings.ShowPreviews,
}
if value := tgReactionNotifyFrom(settings.MessagesFrom); value != nil {
out.SetMessagesNotifyFrom(value)
}
if value := tgReactionNotifyFrom(settings.StoriesFrom); value != nil {
out.SetStoriesNotifyFrom(value)
}
if value := tgReactionNotifyFrom(settings.PollVotesFrom); value != nil {
out.SetPollVotesNotifyFrom(value)
}
return out
}
func tgReactionNotifyFrom(value domain.ReactionNotifyFrom) tg.ReactionNotificationsFromClass {
switch value {
case domain.ReactionNotifyFromAll:
return &tg.ReactionNotificationsFromAll{}
case domain.ReactionNotifyFromContacts:
return &tg.ReactionNotificationsFromContacts{}
default:
return nil
}
}
func (r *Router) onAccountUpdateProfile(ctx context.Context, req *tg.AccountUpdateProfileRequest) (tg.UserClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.deps.Users.(UserIdentityService)
if !ok {
return nil, internalErr()
}
firstName, hasFirstName := req.GetFirstName()
lastName, hasLastName := req.GetLastName()
about, hasAbout := req.GetAbout()
u, err := svc.UpdateProfile(ctx, userID, domain.UserProfileUpdate{
FirstName: firstName,
HasFirstName: hasFirstName,
LastName: lastName,
HasLastName: hasLastName,
About: about,
HasAbout: hasAbout,
})
if err != nil {
return nil, profileErr(err)
}
r.pushUsernameUpdate(ctx, u)
return r.tgSelfUser(u), nil
}
func (r *Router) onAccountCheckUsername(ctx context.Context, username string) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
svc, ok := r.deps.Users.(UserIdentityService)
if !ok {
return false, internalErr()
}
okUsername, err := svc.CheckUsername(ctx, userID, username)
if err != nil {
return false, usernameErr(err)
}
return okUsername, nil
}
func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (tg.UserClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.deps.Users.(UserIdentityService)
if !ok {
return nil, internalErr()
}
u, err := svc.UpdateUsername(ctx, userID, username)
if err != nil {
return nil, usernameErr(err)
}
r.pushUsernameUpdate(ctx, u)
return r.tgSelfUser(u), nil
}
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
if u.ID == 0 {
return
}
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserName{
UserID: u.ID,
FirstName: u.FirstName,
LastName: u.LastName,
Usernames: tgUsernames(u.Username),
}},
Users: []tg.UserClass{r.tgSelfUser(u)},
Date: int(r.clock.Now().Unix()),
})
}
func usernameErr(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.ErrUsernameNotOccupied):
return usernameNotOccupiedErr()
default:
return internalErr()
}
}
func profileErr(err error) error {
switch {
case errors.Is(err, domain.ErrFirstNameInvalid):
return firstNameInvalidErr()
case errors.Is(err, domain.ErrAboutTooLong):
return aboutTooLongErr()
default:
return internalErr()
}
}

16
internal/rpc/aicompose.go Normal file
View file

@ -0,0 +1,16 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
)
// registerAiCompose 注册第一阶段 TDesktop 启动所需 aicompose.* RPC 兼容响应。
func (r *Router) registerAiCompose(d *tg.ServerDispatcher) {
d.OnAicomposeGetTones(func(ctx context.Context, hash int64) (tg.AicomposeTonesClass, error) {
return tdesktop.AiComposeTones(), nil
})
}

347
internal/rpc/auth.go Normal file
View file

@ -0,0 +1,347 @@
package rpc
import (
"context"
"encoding/binary"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// devCodeLength 是开发固定验证码长度,写入 auth.sentCode 的 type.length。
const devCodeLength = 5
const loginMessagePushDelay = 2 * time.Second
// registerAuth 注册 auth.* RPC handler。
func (r *Router) registerAuth(d *tg.ServerDispatcher) {
d.OnAuthBindTempAuthKey(r.onAuthBindTempAuthKey)
d.OnAuthExportLoginToken(r.onAuthExportLoginToken)
d.OnAuthSendCode(r.onAuthSendCode)
d.OnAuthSignIn(r.onAuthSignIn)
d.OnAuthSignUp(r.onAuthSignUp)
d.OnAuthLogOut(r.onAuthLogOut)
}
// onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。
func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTempAuthKeyRequest) (bool, error) {
if r.deps.Auth == nil {
return true, nil
}
id, _ := RawAuthKeyIDFrom(ctx)
if id == ([8]byte{}) {
id, _ = AuthKeyIDFrom(ctx)
}
sessionID, _ := SessionIDFrom(ctx)
if err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: id,
PermAuthKeyID: req.PermAuthKeyID,
Nonce: req.Nonce,
ExpiresAt: req.ExpiresAt,
EncryptedMessage: append([]byte(nil), req.EncryptedMessage...),
}); err != nil {
return false, bindTempAuthKeyErr(err)
}
if r.deps.Sessions != nil {
if scoped, ok := r.scopedSessions(); ok {
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, authKeyIDFromInt64(req.PermAuthKeyID))
} else {
r.deps.Sessions.BindAuthKey(sessionID, authKeyIDFromInt64(req.PermAuthKeyID))
}
}
r.invalidateAuthUserCache(id)
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
}
// 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)
if err != nil {
return nil, internalErr()
}
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength},
PhoneCodeHash: hash,
}, nil
}
// onAuthSignIn 处理 auth.signIn校验验证码用户不存在时返回 SignUpRequired。
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)
if err != nil {
return nil, signInErr(err)
}
if needSignUp {
return &tg.AuthAuthorizationSignUpRequired{}, nil
}
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)
r.recordAndScheduleLoginMessagePush(ctx, loginMessage)
r.pushSignInServiceNotificationToOthers(ctx, u)
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)
if err != nil {
return nil, signInErr(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)
r.recordAndScheduleLoginMessagePush(ctx, loginMessage)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
// onAuthLogOut 处理 auth.logOut解绑当前 auth_key 的授权。
func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, authorized, userErr := r.currentUserID(ctx)
if err := r.deps.Auth.LogOut(ctx, id); err != nil {
return nil, internalErr()
}
r.invalidateAuthUserCache(id)
r.unbindAuthKey(id)
if userErr == nil && authorized && userID != 0 {
status := r.setPresenceFromContext(ctx, userID, true)
r.pushUserStatus(ctx, userID, status)
}
if err := r.clearAuthKeyState(ctx, id); err != nil {
return nil, internalErr()
}
return &tg.AuthLoggedOut{}, nil
}
func (r *Router) clearAuthKeyStateOnUserChange(ctx context.Context, newUserID int64) error {
oldUserID, ok := UserIDFrom(ctx)
if !ok || oldUserID == 0 || oldUserID == newUserID {
return nil
}
id, ok := AuthKeyIDFrom(ctx)
if !ok {
return nil
}
return r.clearAuthKeyState(ctx, id)
}
func (r *Router) clearAuthKeyState(ctx context.Context, authKeyID [8]byte) error {
if r.deps.Updates == nil {
return nil
}
return r.deps.Updates.ClearAuthKey(ctx, authKeyID)
}
func (r *Router) bindSessionUser(ctx context.Context, userID int64) {
if r.deps.Sessions == nil {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
if scoped, ok := r.scopedSessions(); ok {
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID)
r.announceSessionOnline(ctx, userID)
return
}
r.deps.Sessions.BindUser(sessionID, userID)
r.announceSessionOnline(ctx, userID)
}
func (r *Router) unbindAuthKey(authKeyID [8]byte) {
if r.deps.Sessions == nil {
return
}
r.deps.Sessions.UnbindAuthKey(authKeyID)
}
func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u domain.User) {
if r.deps.Sessions == nil || u.ID == 0 {
return
}
authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx)
sessionID, hasSessionID := SessionIDFrom(ctx)
if !hasAuthKeyID || !hasSessionID {
return
}
notification := r.tgSignInServiceNotification(ctx, u, authKeyID)
go func() {
pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if scoped, ok := r.scopedSessions(); ok {
if sent, err := scoped.PushToUserExceptAuthKeySession(pushCtx, u.ID, authKeyID, sessionID, proto.MessageFromServer, notification); err != nil {
r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err))
}
return
}
if sent, err := r.deps.Sessions.PushToUserExceptSession(pushCtx, u.ID, sessionID, proto.MessageFromServer, notification); err != nil {
r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err))
}
}()
}
func (r *Router) recordAndScheduleLoginMessagePush(ctx context.Context, msg domain.Message) {
authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx)
sessionID, hasSessionID := SessionIDFrom(ctx)
if !hasAuthKeyID || !hasSessionID || msg.ID == 0 {
return
}
event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: msg.Date, Message: msg}
state := domain.UpdateState{Pts: 1, Date: msg.Date, Seq: 0}
if r.deps.Updates != nil {
recorded, st, err := r.deps.Updates.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg)
if err != nil {
r.log.Warn("record login message update", zap.Error(err))
return
}
event = recorded
state = st
}
if r.deps.Sessions == nil {
return
}
// 提前从请求 ctx 取出 rawAuthKeyID值类型闭包只捕获该值、不捕获请求 ctx——
// 避免延迟推送的 AfterFunc 在 loginMessagePushDelay 期间延长请求 ctx 链路的存活。
rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx)
time.AfterFunc(loginMessagePushDelay, func() {
pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r.pushLoginMessage(pushCtx, rawAuthKeyID, sessionID, event, state)
})
}
func (r *Router) pushLoginMessage(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, event domain.UpdateEvent, state domain.UpdateState) {
if r.deps.Sessions == nil || event.Message.ID == 0 {
return
}
updates := tgLoginMessageUpdates(event, state)
if updates == nil {
return
}
var err error
if scoped, ok := r.scopedSessions(); ok && rawAuthKeyID != ([8]byte{}) {
err = scoped.PushToSessionForAuthKey(ctx, rawAuthKeyID, sessionID, proto.MessageFromServer, updates)
} else {
err = r.deps.Sessions.PushToSession(ctx, sessionID, proto.MessageFromServer, updates)
}
if err != nil {
r.log.Debug("push login message", zap.Int64("session_id", sessionID), zap.Error(err))
return
}
r.log.Debug("pushed login message",
zap.Int64("session_id", sessionID),
zap.Int("message_id", event.Message.ID),
zap.Int("pts", event.Pts),
zap.Int("seq", state.Seq),
)
}
func tgLoginMessageUpdates(event domain.UpdateEvent, state domain.UpdateState) *tg.Updates {
item := tgMessage(event.Message)
if item == nil {
return nil
}
if state.Date == 0 {
state.Date = event.Date
}
return &tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdateNewMessage{
Message: item,
Pts: event.Pts,
PtsCount: event.PtsCount,
},
},
Users: []tg.UserClass{tgUser(domain.OfficialSystemUser())},
Date: state.Date,
Seq: state.Seq,
}
}
func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User, authKeyID [8]byte) *tg.Updates {
now := r.clock.Now()
client := "Unknown device"
if ci, ok := ClientInfoFrom(ctx); ok {
parts := []string{}
if ci.DeviceModel != "" {
parts = append(parts, ci.DeviceModel)
}
if ci.SystemVersion != "" {
parts = append(parts, ci.SystemVersion)
}
if ci.AppVersion != "" {
parts = append(parts, ci.AppVersion)
}
if len(parts) > 0 {
client = strings.Join(parts, " / ")
}
}
name := strings.TrimSpace(strings.TrimSpace(u.FirstName + " " + u.LastName))
if name == "" {
name = u.Phone
}
if name == "" {
name = "there"
}
message := fmt.Sprintf("New login.\nDear %s, we detected a login into your account from a new device on %s.\n\nDevice: %s\nLocation: Unknown\n\nIf this wasn't you, you can terminate that session in Settings > Devices (or Privacy & Security > Active Sessions).",
name,
now.UTC().Format(time.RFC1123),
client,
)
authID := int64(binary.LittleEndian.Uint64(authKeyID[:]))
update := &tg.UpdateServiceNotification{
InboxDate: int(now.Unix()),
Type: fmt.Sprintf("auth%d_%d", authID, now.Unix()),
Message: message,
Media: &tg.MessageMediaEmpty{},
Entities: signInNotificationEntities(message),
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(now.Unix()),
}
}
func signInNotificationEntities(message string) []tg.MessageEntityClass {
terms := []string{"New login.", "Settings > Devices", "Privacy & Security > Active Sessions"}
out := make([]tg.MessageEntityClass, 0, len(terms))
for _, term := range terms {
if offset := strings.Index(message, term); offset >= 0 {
out = append(out, &tg.MessageEntityBold{Offset: offset, Length: len(term)})
}
}
return out
}
func authKeyIDFromInt64(v int64) [8]byte {
var id [8]byte
binary.LittleEndian.PutUint64(id[:], uint64(v))
return id
}

View file

@ -0,0 +1,182 @@
package rpc
import (
"context"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const channelMembershipSyncPageSize = domain.MaxSynchronousChannelDialogFanout
func (r *Router) trackChannelInterest(ctx context.Context, userID int64, channelIDs ...int64) {
if userID == 0 || r.deps.Sessions == nil {
return
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return
}
rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx)
if !ok {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
if len(channelIDs) == 0 {
provider.ClearChannelInterest(rawAuthKeyID, sessionID, userID)
return
}
provider.TrackChannelInterest(rawAuthKeyID, sessionID, userID, channelIDs)
}
func (r *Router) clearChannelInterest(ctx context.Context, userID int64) {
r.trackChannelInterest(ctx, userID)
}
func (r *Router) syncSessionChannelMemberships(ctx context.Context, userID int64) {
if userID == 0 || r.deps.Sessions == nil || r.deps.Channels == nil {
return
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return
}
rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx)
if !ok {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
channelIDs := make([]int64, 0, channelMembershipSyncPageSize)
after := int64(0)
for {
page, err := r.deps.Channels.ActiveChannelIDsForUser(ctx, userID, after, channelMembershipSyncPageSize)
if err != nil {
r.log.Warn("sync session channel memberships failed",
zap.Int64("user_id", userID),
zap.Int64("after_channel_id", after),
zap.Error(err))
return
}
if len(page) == 0 {
break
}
progressed := false
for _, channelID := range page {
if channelID == 0 {
continue
}
channelIDs = append(channelIDs, channelID)
if channelID > after {
after = channelID
progressed = true
}
}
if !progressed || len(page) < channelMembershipSyncPageSize {
break
}
}
provider.SetSessionChannelMemberships(rawAuthKeyID, sessionID, userID, channelIDs)
}
func (r *Router) addOnlineChannelMemberships(channelID int64, userIDs ...int64) {
if channelID == 0 || len(userIDs) == 0 || r.deps.Sessions == nil {
return
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return
}
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
provider.AddUserChannelMembership(userID, channelID)
}
}
func (r *Router) removeOnlineChannelMemberships(channelID int64, userIDs ...int64) {
if channelID == 0 || len(userIDs) == 0 || r.deps.Sessions == nil {
return
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return
}
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
provider.RemoveUserChannelMembership(userID, channelID)
}
}
func (r *Router) removeOnlineChannelMembershipsForOnlineMembers(channelID int64) {
if channelID == 0 || r.deps.Sessions == nil {
return
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return
}
r.removeOnlineChannelMemberships(channelID, provider.OnlineChannelMemberUserIDs(channelID, 0)...)
}
func channelMemberUserIDs(members []domain.ChannelMember) []int64 {
if len(members) == 0 {
return nil
}
ids := make([]int64, 0, len(members))
for _, member := range members {
if member.UserID == 0 || member.Status != domain.ChannelMemberActive {
continue
}
ids = append(ids, member.UserID)
}
return ids
}
func channelIDsFromDialogs(list domain.DialogList) []int64 {
if len(list.Dialogs) == 0 && len(list.Channels) == 0 {
return nil
}
ids := make([]int64, 0, len(list.Dialogs)+len(list.Channels))
seen := make(map[int64]struct{}, len(list.Dialogs)+len(list.Channels))
for _, d := range list.Dialogs {
if d.Peer.Type != domain.PeerTypeChannel || d.Peer.ID == 0 {
continue
}
if _, ok := seen[d.Peer.ID]; ok {
continue
}
seen[d.Peer.ID] = struct{}{}
ids = append(ids, d.Peer.ID)
}
for _, ch := range list.Channels {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
ids = append(ids, ch.ID)
}
return ids
}

4017
internal/rpc/channels.go Normal file

File diff suppressed because it is too large Load diff

562
internal/rpc/contacts.go Normal file
View file

@ -0,0 +1,562 @@
package rpc
import (
"context"
"errors"
"strings"
"unicode/utf8"
"github.com/gotd/td/tg"
"telesrv/internal/app/contacts"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
const (
maxContactImportBatch = 500
maxContactDeleteBatch = 500
maxContactNameLength = 128
maxContactPhoneLength = 64
maxContactNoteLength = 4096
maxContactSearchQLen = 256
maxContactSearchLimit = 50
)
// registerContacts 注册 contacts.* RPC handler。
func (r *Router) registerContacts(d *tg.ServerDispatcher) {
d.OnContactsGetContacts(r.onContactsGetContacts)
d.OnContactsGetContactIDs(r.onContactsGetContactIDs)
d.OnContactsGetStatuses(r.onContactsGetStatuses)
d.OnContactsImportContacts(r.onContactsImportContacts)
d.OnContactsAddContact(r.onContactsAddContact)
d.OnContactsDeleteContacts(r.onContactsDeleteContacts)
d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote)
d.OnContactsSearch(r.onContactsSearch)
d.OnContactsResolveUsername(r.onContactsResolveUsername)
d.OnContactsResolvePhone(r.onContactsResolvePhone)
d.OnContactsGetTopPeers(func(ctx context.Context, req *tg.ContactsGetTopPeersRequest) (tg.ContactsTopPeersClass, error) {
return tdesktop.TopPeers(), nil
})
d.OnContactsGetBlocked(func(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) {
if req.Limit > 50 {
return nil, limitInvalidErr()
}
return tdesktop.BlockedContacts(), nil
})
d.OnContactsGetSponsoredPeers(func(ctx context.Context, q string) (tg.ContactsSponsoredPeersClass, error) {
if utf8.RuneCountInString(q) > maxContactSearchQLen {
return nil, limitInvalidErr()
}
return &tg.ContactsSponsoredPeersEmpty{}, nil
})
}
func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.ContactsContactsClass, error) {
if r.deps.Contacts == nil {
return &tg.ContactsContacts{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
list, notModified, err := r.deps.Contacts.GetContacts(ctx, userID, hash)
if err != nil {
return nil, internalErr()
}
if notModified {
return &tg.ContactsContactsNotModified{}, nil
}
return tgContacts(r.withContactListPresence(list)), nil
}
func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus, error) {
if r.deps.Contacts == nil {
return []tg.ContactStatus{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
list, _, err := r.deps.Contacts.GetContacts(ctx, userID, 0)
if err != nil {
return nil, internalErr()
}
contactUserIDs := make([]int64, 0, len(list.Contacts))
out := make([]tg.ContactStatus, 0, len(list.Contacts))
seen := make(map[int64]struct{}, len(list.Contacts))
for _, contact := range list.Contacts {
id := contact.User.ID
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
contactUserIDs = append(contactUserIDs, id)
}
usersByID := make(map[int64]domain.User, len(contactUserIDs))
if len(contactUserIDs) > 0 && r.deps.Users != nil {
users, err := r.deps.Users.ByIDs(ctx, userID, contactUserIDs)
if err != nil {
return nil, internalErr()
}
for _, u := range users {
if u.ID != 0 {
usersByID[u.ID] = u
}
}
}
seen = make(map[int64]struct{}, len(list.Contacts))
for _, contact := range list.Contacts {
id := contact.User.ID
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
u := contact.User
if current, ok := usersByID[id]; ok {
u.LastSeenAt = current.LastSeenAt
u.Status = current.Status
}
out = append(out, tg.ContactStatus{
UserID: id,
Status: tgUserStatus(r.userPresenceStatusForUser(u)),
})
}
return out, nil
}
func (r *Router) onContactsGetContactIDs(ctx context.Context, hash int64) ([]int, error) {
if r.deps.Contacts == nil {
return nil, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, hash)
if err != nil {
return nil, internalErr()
}
if notModified {
return nil, nil
}
return ids, nil
}
func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputPhoneContact) (*tg.ContactsImportedContacts, error) {
if r.deps.Contacts == nil {
return &tg.ContactsImportedContacts{}, nil
}
if len(input) > maxContactImportBatch {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
items := make([]domain.ContactInput, 0, len(input))
for _, item := range input {
note, entities := contactNote(item.GetNote())
if !validContactInput(item.Phone, item.FirstName, item.LastName, note, len(entities)) {
return nil, limitInvalidErr()
}
items = append(items, domain.ContactInput{
ClientID: item.ClientID,
Phone: item.Phone,
FirstName: item.FirstName,
LastName: item.LastName,
Note: note,
NoteEntities: entities,
})
}
res, err := r.deps.Contacts.ImportContacts(ctx, userID, items)
if err != nil {
return nil, internalErr()
}
out := &tg.ContactsImportedContacts{
Imported: make([]tg.ImportedContact, 0, len(res.Imported)),
Users: make([]tg.UserClass, 0, len(res.Contacts)),
}
for _, imported := range res.Imported {
out.Imported = append(out.Imported, tg.ImportedContact{UserID: imported.UserID, ClientID: imported.ClientID})
}
for _, contact := range res.Contacts {
out.Users = append(out.Users, r.tgUser(contact.User))
}
out.RetryContacts = append(out.RetryContacts, res.RetryContacts...)
for _, contact := range res.Contacts {
if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil {
return nil, internalErr()
}
}
if err := r.recordContactsReset(ctx, userID); err != nil {
return nil, internalErr()
}
r.pushContactsReset(ctx, userID)
return out, nil
}
func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddContactRequest) (tg.UpdatesClass, error) {
if r.deps.Contacts == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
target, found, err := r.userFromInput(ctx, userID, req.ID)
if err != nil {
return nil, contactErr(err)
}
if !found {
return nil, contactIDInvalidErr()
}
note, entities := contactNote(req.GetNote())
if !validContactInput(req.Phone, req.FirstName, req.LastName, note, len(entities)) {
return nil, limitInvalidErr()
}
contact, err := r.deps.Contacts.AddContact(ctx, userID, domain.ContactInput{
ContactUserID: target.ID,
Phone: req.Phone,
FirstName: req.FirstName,
LastName: req.LastName,
Note: note,
NoteEntities: entities,
AddPhonePrivacyException: req.AddPhonePrivacyException,
})
if err != nil {
return nil, contactErr(err)
}
updates := r.contactPeerSettingsUpdates(ctx, userID, contact.User, domain.PeerSettings{ShareContact: true}, true)
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil {
return nil, internalErr()
}
if err := r.recordContactsReset(ctx, userID); err != nil {
return nil, internalErr()
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
return updates, nil
}
func (r *Router) onContactsDeleteContacts(ctx context.Context, ids []tg.InputUserClass) (tg.UpdatesClass, error) {
if r.deps.Contacts == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil
}
if len(ids) > maxContactDeleteBatch {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
contactIDs := make([]int64, 0, len(ids))
users := make([]tg.UserClass, 0, len(ids)+1)
if r.deps.Users != nil {
if u, err := r.deps.Users.Self(ctx, userID); err == nil {
users = append(users, r.tgSelfUser(u))
}
}
seen := map[int64]struct{}{userID: {}}
for _, id := range ids {
u, found, err := r.userFromInput(ctx, userID, id)
if err != nil {
return nil, contactErr(err)
}
if !found || u.ID == userID {
continue
}
contactIDs = append(contactIDs, u.ID)
u.Contact = false
u.Mutual = false
if _, ok := seen[u.ID]; !ok {
users = append(users, r.tgUser(u))
seen[u.ID] = struct{}{}
}
}
if _, err := r.deps.Contacts.DeleteContacts(ctx, userID, contactIDs); err != nil {
return nil, internalErr()
}
updates := make([]tg.UpdateClass, 0, len(contactIDs))
for _, id := range contactIDs {
updates = append(updates, &tg.UpdatePeerSettings{
Peer: &tg.PeerUser{UserID: id},
Settings: tgPeerSettings(domain.PeerSettings{AddContact: true, BlockContact: true}),
})
}
if len(contactIDs) > 0 {
for _, id := range contactIDs {
if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: id}, domain.PeerSettings{AddContact: true, BlockContact: true}); err != nil {
return nil, internalErr()
}
}
updates = append(updates, &tg.UpdateContactsReset{})
if err := r.recordContactsReset(ctx, userID); err != nil {
return nil, internalErr()
}
}
out := &tg.Updates{Updates: updates, Users: users, Date: int(r.clock.Now().Unix())}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
return out, nil
}
func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.ContactsUpdateContactNoteRequest) (bool, error) {
if r.deps.Contacts == nil {
return true, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, found, err := r.userFromInput(ctx, userID, req.ID)
if err != nil {
return false, contactErr(err)
}
if !found {
return false, contactIDInvalidErr()
}
if utf8.RuneCountInString(req.Note.Text) > maxContactNoteLength || len(req.Note.Entities) > maxMessageEntityCount {
return false, limitInvalidErr()
}
if _, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, req.Note.Text, domainMessageEntities(req.Note.Entities)); err != nil {
return false, contactErr(err)
}
if err := r.recordContactsReset(ctx, userID); err != nil {
return false, internalErr()
}
r.pushContactsReset(ctx, userID)
return true, nil
}
func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchRequest) (*tg.ContactsFound, error) {
if r.deps.Contacts == nil && r.deps.Channels == nil {
return &tg.ContactsFound{}, nil
}
query := normalizeSearchQuery(req.Q)
if query == "" {
return nil, searchQueryEmptyErr()
}
if utf8.RuneCountInString(query) < 3 {
return nil, queryTooShortErr()
}
if utf8.RuneCountInString(query) > maxContactSearchQLen {
return nil, limitInvalidErr()
}
limit := req.Limit
if limit <= 0 || limit > maxContactSearchLimit {
limit = maxContactSearchLimit
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
res := domain.UserSearchResult{}
if r.deps.Contacts != nil {
userRes, err := r.deps.Contacts.Search(ctx, userID, query, limit)
if err != nil {
return nil, internalErr()
}
res = userRes
}
if r.deps.Channels != nil {
channelRes, err := r.deps.Channels.SearchPublicChannels(ctx, userID, query, limit)
if err != nil {
return nil, channelInvalidErr(err)
}
res.MyChannelResults = channelRes.MyResults
res.ChannelResults = channelRes.Results
}
return tgContactsFound(userID, r.withUserSearchPresence(res)), nil
}
func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.ContactsResolveUsernameRequest) (*tg.ContactsResolvedPeer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if svc, ok := r.deps.Users.(UserIdentityService); ok {
u, found, err := svc.ResolveUsername(ctx, userID, req.Username)
if err != nil {
return nil, usernameErr(err)
}
if found {
return r.tgResolvedUserPeer(userID, u), nil
}
}
if r.deps.Channels != nil {
ch, found, err := r.deps.Channels.ResolvePublicUsername(ctx, userID, req.Username)
if err != nil {
return nil, usernameErr(err)
}
if found {
return tgResolvedChannelPeer(userID, ch), nil
}
}
return nil, usernameNotOccupiedErr()
}
func (r *Router) onContactsResolvePhone(ctx context.Context, phone string) (*tg.ContactsResolvedPeer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
svc, ok := r.deps.Users.(UserIdentityService)
if !ok {
return nil, phoneNotOccupiedErr()
}
u, found, err := svc.ResolvePhone(ctx, userID, phone)
if err != nil {
if errors.Is(err, domain.ErrPhoneNotOccupied) {
return nil, phoneNotOccupiedErr()
}
return nil, internalErr()
}
if !found {
return nil, phoneNotOccupiedErr()
}
return r.tgResolvedUserPeer(userID, u), nil
}
func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.ContactsResolvedPeer {
var user tg.UserClass
if u.ID == currentUserID {
user = r.tgSelfUser(u)
} else {
user = r.tgUser(u)
}
return &tg.ContactsResolvedPeer{
Peer: &tg.PeerUser{UserID: u.ID},
Users: []tg.UserClass{user},
}
}
func tgResolvedChannelPeer(currentUserID int64, ch domain.Channel) *tg.ContactsResolvedPeer {
return &tg.ContactsResolvedPeer{
Peer: &tg.PeerChannel{ChannelID: ch.ID},
Chats: []tg.ChatClass{tgChannelChat(currentUserID, ch, nil)},
}
}
func normalizeSearchQuery(query string) string {
query = strings.TrimSpace(query)
query = strings.TrimPrefix(query, "@")
return strings.TrimSpace(query)
}
func validContactInput(phone, firstName, lastName, note string, entities int) bool {
if utf8.RuneCountInString(phone) > maxContactPhoneLength {
return false
}
if utf8.RuneCountInString(firstName) > maxContactNameLength || utf8.RuneCountInString(lastName) > maxContactNameLength {
return false
}
if utf8.RuneCountInString(note) > maxContactNoteLength || entities > maxMessageEntityCount {
return false
}
return true
}
func contactNote(note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity) {
if !ok {
return "", nil
}
return note.Text, domainMessageEntities(note.Entities)
}
func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, peerUser domain.User, settings domain.PeerSettings, includeSelf bool) *tg.Updates {
users := make([]tg.UserClass, 0, 2)
if includeSelf && r.deps.Users != nil {
if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 {
users = append(users, r.tgSelfUser(self))
}
}
users = append(users, r.tgUser(peerUser))
return &tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdatePeerSettings{
Peer: &tg.PeerUser{UserID: peerUser.ID},
Settings: tgPeerSettings(settings),
},
},
Users: users,
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func (r *Router) pushContactsReset(ctx context.Context, userID int64) {
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateContactsReset{}},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
}
func (r *Router) recordContactsReset(ctx context.Context, userID int64) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
_, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, sessionID)
return err
}
func (r *Router) recordPeerSettings(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)
_, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID)
return err
}
type reliableUpdateDispatchReporter interface {
UsesReliableDispatch() bool
}
func (r *Router) hasReliableUpdateDispatch() bool {
reporter, ok := r.deps.Updates.(reliableUpdateDispatchReporter)
return ok && reporter.UsesReliableDispatch()
}
func (r *Router) pushUserUpdatesIfNoReliableDispatch(ctx context.Context, userID int64, updates *tg.Updates) {
if r.hasReliableUpdateDispatch() {
return
}
r.pushUserUpdates(ctx, userID, updates)
}
func (r *Router) pushUserUpdates(ctx context.Context, userID int64, updates *tg.Updates) int {
return r.pushUserMessage(ctx, userID, "push user updates", updates)
}
func tgPeerSettings(settings domain.PeerSettings) tg.PeerSettings {
if settings.HiddenPeerSettingsBar {
return tg.PeerSettings{}
}
return tg.PeerSettings{
AddContact: settings.AddContact,
BlockContact: settings.BlockContact,
ShareContact: settings.ShareContact,
NeedContactsException: settings.NeedContactsException,
}
}
func contactErr(err error) error {
switch {
case errors.Is(err, contacts.ErrContactNameEmpty):
return contactNameEmptyErr()
case errors.Is(err, contacts.ErrContactIDInvalid):
return contactIDInvalidErr()
default:
return internalErr()
}
}

96
internal/rpc/context.go Normal file
View file

@ -0,0 +1,96 @@
package rpc
import "context"
type ctxKey int
const (
layerKey ctxKey = iota
clientInfoKey
rawAuthKeyIDKey
authKeyIDKey
sessionIDKey
userIDKey
)
// ClientInfo 是 initConnection 携带的客户端信息。
type ClientInfo struct {
APIID int
DeviceModel string
SystemVersion string
AppVersion string
SystemLangCode string
LangPack string
LangCode string
}
// WithLayer 在 ctx 注入客户端 layer来自 invokeWithLayer
func WithLayer(ctx context.Context, layer int) context.Context {
return context.WithValue(ctx, layerKey, layer)
}
// LayerFrom 返回 ctx 中的客户端 layer未设置时为 0。
func LayerFrom(ctx context.Context) int {
if v, ok := ctx.Value(layerKey).(int); ok {
return v
}
return 0
}
// WithClientInfo 在 ctx 注入客户端信息(来自 initConnection
func WithClientInfo(ctx context.Context, info ClientInfo) context.Context {
return context.WithValue(ctx, clientInfoKey, info)
}
// ClientInfoFrom 返回 ctx 中的客户端信息。
func ClientInfoFrom(ctx context.Context) (ClientInfo, bool) {
v, ok := ctx.Value(clientInfoKey).(ClientInfo)
return v, ok
}
// WithRawAuthKeyID 在 ctx 注入连接实际使用的 auth_key_id。
func WithRawAuthKeyID(ctx context.Context, id [8]byte) context.Context {
return context.WithValue(ctx, rawAuthKeyIDKey, id)
}
// RawAuthKeyIDFrom 返回连接实际使用的 auth_key_id。
func RawAuthKeyIDFrom(ctx context.Context) ([8]byte, bool) {
v, ok := ctx.Value(rawAuthKeyIDKey).([8]byte)
return v, ok
}
// WithAuthKeyID 在 ctx 注入业务视角的 auth_key_idtemp auth_key 绑定后会解析为 perm auth_key。
func WithAuthKeyID(ctx context.Context, id [8]byte) context.Context {
return context.WithValue(ctx, authKeyIDKey, id)
}
// AuthKeyIDFrom 返回 ctx 中业务视角的 auth_key_id。已握手连接均有即便尚未登录
func AuthKeyIDFrom(ctx context.Context) ([8]byte, bool) {
v, ok := ctx.Value(authKeyIDKey).([8]byte)
return v, ok
}
// WithSessionID 在 ctx 注入调用方的 MTProto session_id。
func WithSessionID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, sessionIDKey, id)
}
// SessionIDFrom 返回 ctx 中调用方的 MTProto session_id。
func SessionIDFrom(ctx context.Context) (int64, bool) {
v, ok := ctx.Value(sessionIDKey).(int64)
return v, ok
}
// WithUserID 在 ctx 注入当前已登录用户 id。
func WithUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, userIDKey, id)
}
// UserIDFrom 返回 ctx 中当前已登录用户 id。
func UserIDFrom(ctx context.Context) (int64, bool) {
v, ok := ctx.Value(userIDKey).(int64)
if !ok || v == 0 {
return 0, false
}
return v, true
}

1919
internal/rpc/convert.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,385 @@
package rpc
import (
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// 本文件集中 domain media 值对象 → tg.* 的转换tg.* 只在 rpc 层出现。
// 供 reaction / sticker 资源 RPC 与消息 media 共用。
// tgMessageMedia 把消息 media 快照转成 tg.MessageMediaClass空载荷回退 MessageMediaEmpty。
func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
if m.IsZero() {
return &tg.MessageMediaEmpty{}
}
switch m.Kind {
case domain.MessageMediaKindPhoto:
out := &tg.MessageMediaPhoto{Spoiler: m.Spoiler}
if m.Photo != nil {
out.Photo = tgPhoto(*m.Photo)
}
if m.TTLSeconds > 0 {
out.TTLSeconds = m.TTLSeconds
}
return out
case domain.MessageMediaKindDocument:
out := &tg.MessageMediaDocument{
Spoiler: m.Spoiler,
Nopremium: m.Nopremium,
Voice: m.Voice,
Round: m.Round,
Video: m.Video,
}
if m.Document != nil {
out.Document = tgDocument(*m.Document)
}
if m.TTLSeconds > 0 {
out.TTLSeconds = m.TTLSeconds
}
return out
default:
return &tg.MessageMediaEmpty{}
}
}
// tgChatPhoto 由 domain.Channel 反范式头像字段构造 ChatPhoto频道/群头像缩略)。
func tgChatPhoto(ch domain.Channel) tg.ChatPhotoClass {
if ch.PhotoID == 0 {
return &tg.ChatPhotoEmpty{}
}
p := &tg.ChatPhoto{PhotoID: ch.PhotoID, DCID: ch.PhotoDCID}
if len(ch.PhotoStripped) > 0 {
p.SetStrippedThumb(ch.PhotoStripped)
}
return p
}
// tgChannelChatPhotoFull 为 channelFull.chat_photo 构造完整 Photo合成 a/c 尺寸;
// getFile 按 photo:<id>:<type> 解析,忽略 access_hash故合成尺寸也可下载
func tgChannelChatPhotoFull(ch domain.Channel) tg.PhotoClass {
if ch.PhotoID == 0 {
return &tg.PhotoEmpty{}
}
photo := &tg.Photo{ID: ch.PhotoID, DCID: ch.PhotoDCID, Sizes: syntheticAvatarSizes()}
if len(ch.PhotoStripped) > 0 {
photo.Sizes = append([]tg.PhotoSizeClass{&tg.PhotoStrippedSize{Type: "i", Bytes: ch.PhotoStripped}}, photo.Sizes...)
}
return photo
}
func syntheticAvatarSizes() []tg.PhotoSizeClass {
return []tg.PhotoSizeClass{
&tg.PhotoSize{Type: "a", W: 160, H: 160, Size: 0},
&tg.PhotoSize{Type: "c", W: 640, H: 640, Size: 0},
}
}
// tgPhoto 把 domain.Photo 转成 tg.PhotoClass。
func tgPhoto(p domain.Photo) tg.PhotoClass {
if p.ID == 0 {
return &tg.PhotoEmpty{}
}
return &tg.Photo{
ID: p.ID,
AccessHash: p.AccessHash,
FileReference: p.FileReference,
Date: p.Date,
Sizes: tgPhotoSizes(p.Sizes),
DCID: p.DCID,
HasStickers: p.HasStickers,
}
}
// tgDocument 把 domain.Document 转成 tg.DocumentClass。
func tgDocument(d domain.Document) tg.DocumentClass {
if d.ID == 0 {
return &tg.DocumentEmpty{}
}
return &tg.Document{
ID: d.ID,
AccessHash: d.AccessHash,
FileReference: d.FileReference,
Date: d.Date,
MimeType: d.MimeType,
Size: d.Size,
Thumbs: tgDocumentThumbs(d.Thumbs),
DCID: d.DCID,
Attributes: tgDocumentAttributes(d.Attributes),
}
}
func tgDocuments(docs []domain.Document) []tg.DocumentClass {
out := make([]tg.DocumentClass, 0, len(docs))
for _, d := range docs {
out = append(out, tgDocument(d))
}
return out
}
func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil
}
out := make([]tg.PhotoSizeClass, 0, len(sizes))
for _, s := range sizes {
if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 {
size := s.Size
if size == 0 {
size = len(s.Bytes)
}
if s.Type != "" && s.W > 0 && s.H > 0 && size > 0 {
out = append(out, &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: size})
}
continue
}
out = append(out, tgPhotoSize(s))
}
return compactPhotoSizeClasses(out)
}
func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil
}
out := make([]tg.PhotoSizeClass, 0, len(sizes))
for _, s := range sizes {
out = append(out, tgPhotoSize(s))
}
return compactPhotoSizeClasses(out)
}
func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass {
switch s.Kind {
case domain.PhotoSizeKindDefault:
return &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size}
case domain.PhotoSizeKindStripped:
return &tg.PhotoStrippedSize{Type: s.Type, Bytes: s.Bytes}
case domain.PhotoSizeKindCached:
return &tg.PhotoCachedSize{Type: s.Type, W: s.W, H: s.H, Bytes: s.Bytes}
case domain.PhotoSizeKindPath:
return &tg.PhotoPathSize{Type: s.Type, Bytes: s.Bytes}
case domain.PhotoSizeKindProgressive:
return &tg.PhotoSizeProgressive{Type: s.Type, W: s.W, H: s.H, Sizes: s.Sizes}
default:
return nil
}
}
func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass {
out := in[:0]
for _, s := range in {
if s != nil {
out = append(out, s)
}
}
return out
}
func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass {
out := make([]tg.DocumentAttributeClass, 0, len(attrs))
for _, a := range attrs {
switch a.Kind {
case domain.DocAttrImageSize:
out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H})
case domain.DocAttrAnimated:
out = append(out, &tg.DocumentAttributeAnimated{})
case domain.DocAttrSticker:
out = append(out, &tg.DocumentAttributeSticker{
Mask: a.Mask,
Alt: a.Alt,
Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash),
})
case domain.DocAttrVideo:
out = append(out, &tg.DocumentAttributeVideo{
RoundMessage: a.RoundMessage,
SupportsStreaming: a.SupportsStreaming,
Duration: a.Duration,
W: a.W,
H: a.H,
})
case domain.DocAttrAudio:
attr := &tg.DocumentAttributeAudio{
Voice: a.Voice,
Duration: a.AudioDuration,
Title: a.Title,
Performer: a.Performer,
}
if len(a.Waveform) > 0 {
attr.SetWaveform(a.Waveform)
}
out = append(out, attr)
case domain.DocAttrFilename:
out = append(out, &tg.DocumentAttributeFilename{FileName: a.FileName})
case domain.DocAttrCustomEmoji:
out = append(out, &tg.DocumentAttributeCustomEmoji{
Free: a.Free,
TextColor: a.TextColor,
Alt: a.Alt,
Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash),
})
}
}
return out
}
func tgInputStickerSetFromIDs(id, accessHash int64) tg.InputStickerSetClass {
if id == 0 {
return &tg.InputStickerSetEmpty{}
}
return &tg.InputStickerSetID{ID: id, AccessHash: accessHash}
}
// ---- available reactions ----
// tgAvailableReactions 用真实文档构造 messages.availableReactionsdocByID 由 handler 预加载。
func tgAvailableReactions(reactions []domain.AvailableReaction, docByID map[int64]domain.Document, hash int) *tg.MessagesAvailableReactions {
out := &tg.MessagesAvailableReactions{Hash: hash, Reactions: make([]tg.AvailableReaction, 0, len(reactions))}
doc := func(id int64) tg.DocumentClass {
if d, ok := docByID[id]; ok {
return tgDocument(d)
}
return &tg.DocumentEmpty{ID: id}
}
for _, r := range reactions {
ar := tg.AvailableReaction{
Inactive: r.Inactive,
Premium: r.Premium,
Reaction: r.Reaction,
Title: r.Title,
StaticIcon: doc(r.StaticIconID),
AppearAnimation: doc(r.AppearAnimationID),
SelectAnimation: doc(r.SelectAnimationID),
ActivateAnimation: doc(r.ActivateAnimationID),
EffectAnimation: doc(r.EffectAnimationID),
}
if r.AroundAnimationID != 0 {
ar.SetAroundAnimation(doc(r.AroundAnimationID))
}
if r.CenterIconID != 0 {
ar.SetCenterIcon(doc(r.CenterIconID))
}
out.Reactions = append(out.Reactions, ar)
}
return out
}
// reactionDocumentIDs 收集一组 reaction 引用的全部文档 id用于批量预加载
func reactionDocumentIDs(reactions []domain.AvailableReaction) []int64 {
seen := make(map[int64]struct{})
out := make([]int64, 0, len(reactions)*7)
for _, r := range reactions {
for _, id := range r.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 {
out := tg.StickerSet{
Archived: set.Archived,
Official: set.Official,
Masks: set.Masks,
Emojis: set.Emojis,
ID: set.ID,
AccessHash: set.AccessHash,
Title: set.Title,
ShortName: set.ShortName,
Count: set.Count,
Hash: set.Hash,
}
if set.Installed {
date := set.InstalledDate
if date == 0 {
date = 1
}
out.SetInstalledDate(date)
}
if thumbs := tgStickerSetThumbs(set.Thumbs); len(thumbs) > 0 {
out.SetThumbs(thumbs)
out.SetThumbDCID(set.ThumbDCID)
out.SetThumbVersion(set.ThumbVersion)
}
if set.ThumbDocumentID != 0 {
out.SetThumbDocumentID(set.ThumbDocumentID)
}
return out
}
func tgStickerSetThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil
}
filtered := make([]domain.PhotoSize, 0, len(sizes))
for _, s := range sizes {
if s.Downloadable() {
continue
}
filtered = append(filtered, s)
}
return tgPhotoSizes(filtered)
}
func tgStickerSets(sets []domain.StickerSet) []tg.StickerSet {
out := make([]tg.StickerSet, 0, len(sets))
for _, s := range sets {
out = append(out, tgStickerSet(s))
}
return out
}
func tgStickerPacks(packs []domain.StickerPack) []tg.StickerPack {
out := make([]tg.StickerPack, 0, len(packs))
for _, p := range packs {
out = append(out, tg.StickerPack{Emoticon: p.Emoticon, Documents: append([]int64(nil), p.DocumentIDs...)})
}
return out
}
// tgMessagesStickerSet 构造完整 messages.stickerSetset + packs + documents
func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet {
return &tg.MessagesStickerSet{
Set: tgStickerSet(set),
Packs: tgStickerPacks(set.Packs),
Keywords: []tg.StickerKeyword{},
Documents: tgDocuments(docs),
}
}
// stickerSetRefFromInput 把 tg.InputStickerSet 转成 domain.StickerSetRef。
func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef, bool) {
switch in := input.(type) {
case *tg.InputStickerSetID:
return domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: in.ID, AccessHash: in.AccessHash}, true
case *tg.InputStickerSetShortName:
return domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: in.ShortName}, true
case *tg.InputStickerSetAnimatedEmoji:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji"}, true
case *tg.InputStickerSetAnimatedEmojiAnimations:
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.InputStickerSetDice:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true
default:
return domain.StickerSetRef{}, false
}
}
// mediaCatalogHash 用一组 int64文档/集合 id算稳定 hash供 *NotModified 缓存判定。
func mediaCatalogHash(values []int64) int64 {
var hash uint64
for _, v := range values {
hash ^= uint64(v)
hash = hash*0x4f25 + uint64(v)
}
return int64(hash & 0x7fffffffffffffff)
}

333
internal/rpc/deps.go Normal file
View file

@ -0,0 +1,333 @@
package rpc
import (
"context"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto"
"telesrv/internal/domain"
)
// 本文件按「消费者定义接口」惯例,在 rpc 包定义 Router 依赖的业务服务接口。
// app/* 的 Service 实现它们;微服务化时 gRPC client 同样可实现rpc 层无需改动。
// 接口方法只用 domain 类型与基本类型,不依赖 app 具体包——这是 rpc↔业务的契约边界。
// AuthService 抽象登录/注册业务。
type AuthService interface {
BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error
ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error)
UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error)
SendCode(ctx context.Context, phone string) (string, error)
SignIn(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error)
SignUp(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error)
LogOut(ctx context.Context, authKeyID [8]byte) error
}
// SessionBinder 抽象登录后 session 与 user 的在线绑定。
type SessionBinder interface {
BindAuthKey(sessionID int64, authKeyID [8]byte)
AuthKeyID(sessionID int64) ([8]byte, bool)
BindUser(sessionID, userID int64)
UserID(sessionID int64) (int64, bool)
UserIDResolved(sessionID int64) (userID int64, resolved bool)
UnbindAuthKey(authKeyID [8]byte) int
SetReceivesUpdates(sessionID int64, receives bool)
PushToSession(ctx context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error
PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error)
}
// ScopedSessionBinder 是 SessionBinder 的精确版本:所有定位都带 raw auth_key_id + session_id。
// 生产 mtprotoedge.SessionManager 实现它;测试替身和旧实现可以只实现 SessionBinder。
type ScopedSessionBinder interface {
BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte)
AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool)
BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64)
UserIDForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (int64, bool)
UserIDResolvedForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (userID int64, resolved bool)
SetReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64, receives bool)
PushToSessionForAuthKey(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error
PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error)
}
// BestEffortSessionBinder 是 updates fanout 的短超时推送接口;不用于 RPC result/ack。
type BestEffortSessionBinder interface {
PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error)
}
// ScopedBestEffortSessionBinder 是带 raw auth_key_id 精确排除当前设备的 best-effort 版本。
type ScopedBestEffortSessionBinder interface {
PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error)
}
// OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout.
type OnlineUserProvider interface {
OnlineUserIDs(limit int) []int64
IsUserOnline(userID int64) bool
OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64
TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64)
ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64)
OnlineChannelUserIDs(channelID int64, limit int) []int64
SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64)
AddUserChannelMembership(userID, channelID int64)
RemoveUserChannelMembership(userID, channelID int64)
OnlineChannelMemberUserIDs(channelID int64, limit int) []int64
}
// RateLimiter 抽象 RPC 高频写操作限流。
type RateLimiter interface {
Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
}
// UsersService 抽象用户查询。
type UsersService interface {
Self(ctx context.Context, userID int64) (domain.User, error)
ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error)
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
}
// UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。
type UserIdentityService interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error)
ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error)
}
// AccountService 抽象账号设置查询。
type AccountService interface {
GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error)
}
// HelpService 抽象启动配置与国家区号目录。
type HelpService interface {
GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error)
GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error)
}
// UpdatesService 抽象 update 状态查询。
type UpdatesService interface {
GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error)
CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error)
GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error)
ClearAuthKey(ctx context.Context, authKeyID [8]byte) error
RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error)
RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// ContactsService 抽象通讯录查询。
type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error)
AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error)
Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error)
DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error)
GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error)
}
// DialogsService 抽象会话列表查询。
type DialogsService interface {
GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error)
GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error)
SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error
DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error)
ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error)
ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error
MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error)
UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error)
HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error)
SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error
DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error
ReorderDialogFolders(ctx context.Context, userID int64, order []int) error
ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error
EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
}
// MessagesService 抽象消息历史、搜索与已读。
type MessagesService interface {
SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error)
ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error)
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error)
ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error)
GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error)
SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error)
DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error)
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
}
// ChannelsService 抽象超级群/频道业务。
type ChannelsService interface {
CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error)
CreateChannel(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error)
GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error)
GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error)
GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error)
InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error)
JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error)
LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error)
EditTitle(ctx context.Context, userID int64, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error)
EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error)
EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
DeleteChannel(ctx context.Context, userID int64, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error)
CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error)
UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error)
SearchPublicChannels(ctx context.Context, userID int64, query string, limit int) (domain.PublicChannelSearchResult, error)
SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error)
SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error)
SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error)
SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error)
SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error)
SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error)
SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error)
GetChannelForChangeInfo(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error)
GetMessageViews(ctx context.Context, userID int64, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error)
SetMessageReactions(ctx context.Context, userID int64, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentReactions(ctx context.Context, userID int64) error
SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error)
UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error
ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error)
GetMessageAuthor(ctx context.Context, userID int64, req domain.GetChannelMessageAuthorRequest) (domain.GetChannelMessageAuthorResult, error)
CreateForumTopic(ctx context.Context, userID int64, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error)
EditForumTopic(ctx context.Context, userID int64, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error)
UpdatePinnedForumTopic(ctx context.Context, userID int64, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error)
ReorderPinnedForumTopics(ctx context.Context, userID int64, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error)
DeleteForumTopicHistory(ctx context.Context, userID int64, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error)
GetForumTopics(ctx context.Context, userID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error)
GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
SendMessage(ctx context.Context, userID int64, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error)
EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error)
DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error)
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error)
DeleteParticipantHistory(ctx context.Context, userID int64, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error)
UpdatePinnedMessage(ctx context.Context, userID int64, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error)
ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error)
CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error)
ImportInvite(ctx context.Context, userID int64, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error)
ListExportedInvites(ctx context.Context, userID int64, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error)
GetExportedInvite(ctx context.Context, userID int64, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error)
EditExportedInvite(ctx context.Context, userID int64, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error)
DeleteExportedInvite(ctx context.Context, userID int64, req domain.DeleteChannelInviteRequest) error
DeleteRevokedExportedInvites(ctx context.Context, userID int64, req domain.DeleteRevokedChannelInvitesRequest) error
ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error)
ListInviteImporters(ctx context.Context, userID int64, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error)
PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error)
HideChatJoinRequest(ctx context.Context, userID int64, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error)
HideAllChatJoinRequests(ctx context.Context, userID int64, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error)
CommonChannels(ctx context.Context, userID int64, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error)
LeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error)
InactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error)
ChannelRecommendations(ctx context.Context, userID int64, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error)
DiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error)
SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error)
SetViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error)
GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error)
SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error)
SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error)
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
GetReplies(ctx context.Context, userID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error)
GetUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error)
ReadMentions(ctx context.Context, userID int64, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error)
GetUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error)
ReadReactions(ctx context.Context, userID int64, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error)
GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error)
ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error)
GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error)
ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error)
InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
// FilesService 抽象文件上传分片、下载与媒体document/photo组装。
// 方法只用 domain 类型rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 转换。
type FilesService interface {
SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error)
SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error)
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
// 资源读取reaction / sticker / document
ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error)
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (set domain.StickerSet, documents []domain.Document, found bool, err error)
ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error)
// 头像profile photo与消息媒体组装。
CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error)
CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error)
CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error)
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
GetDocument(ctx context.Context, id int64) (domain.Document, bool, error)
UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error)
SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error)
CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error)
GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error)
DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error)
}
// LangPackService 抽象客户端语言包查询。
type LangPackService interface {
GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error)
GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error)
GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件auth.go / users.go / updates.go
type Deps struct {
Auth AuthService
Account AccountService
Help HelpService
Users UsersService
Updates UpdatesService
Contacts ContactsService
Dialogs DialogsService
Messages MessagesService
Channels ChannelsService
Files FilesService
LangPack LangPackService
Sessions SessionBinder
Limiter RateLimiter
Metrics Metrics
}

5
internal/rpc/doc.go Normal file
View file

@ -0,0 +1,5 @@
// Package rpc 是按 TypeID 路由的 RPC 层:封装 tg.ServerDispatcher或等价薄封装
// 在 handler 边界把 gotd/td/tg 类型转换为内部 domain command/query统一 tgerr.Error 到
// rpc_error 的映射,注入 auth_key_id/session_id/user_id/layer/设备/语言 等上下文,
// 并对未知 RPC 进入 compatibility trace不静默吞掉记入 docs/compatibility-matrix.md
package rpc

194
internal/rpc/errors.go Normal file
View file

@ -0,0 +1,194 @@
package rpc
import (
"errors"
"fmt"
"github.com/gotd/td/tgerr"
"telesrv/internal/app/auth"
"telesrv/internal/domain"
)
// 本文件集中构造所有 rpc_errorerror_code 遵循 Telegram 约定4xx 客户端 / 5xx 服务端),
// error_message 为 SCREAMING_SNAKE_CASE。统一用 tgerr.New由它从 message 解析 Type/Argument
// (正确处理 FLOOD_WAIT_X 等带参数错误),避免手写 &tgerr.Error{} 时重复且易错。
// internalErr 是兜底的服务端错误。
func internalErr() error { return tgerr.New(500, "INTERNAL_SERVER_ERROR") }
// notImplementedErr 表示 RPC 未实现(落兼容矩阵),让客户端继续运行而非断连。
func notImplementedErr() error { return tgerr.New(500, "NOT_IMPLEMENTED") }
// wrapperTooDeepErr 表示 invokeWithLayer/initConnection 等 wrapper 嵌套过深。
func wrapperTooDeepErr() error { return tgerr.New(400, "WRAPPER_TOO_DEEP") }
// inputConstructorInvalidErr 表示客户端传入的 TL 构造器不在当前 RPC 接受范围内。
func inputConstructorInvalidErr() error { return tgerr.New(400, "INPUT_CONSTRUCTOR_INVALID") }
// folderIDInvalidErr 表示客户端传入多个 folder peer 或非法 folder。
func folderIDInvalidErr() error { return tgerr.New(400, "FOLDER_ID_INVALID") }
func filterIDInvalidErr() error { return tgerr.New(400, "FILTER_ID_INVALID") }
func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") }
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") }
func parentPeerInvalidErr() error { return tgerr.New(400, "PARENT_PEER_INVALID") }
func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_PEER_INVALID") }
func limitInvalidErr() error { return tgerr.New(400, "LIMIT_INVALID") }
func secondsInvalidErr() error { return tgerr.New(400, "SECONDS_INVALID") }
func ttlPeriodInvalidErr() error { return tgerr.New(400, "TTL_PERIOD_INVALID") }
func chatInvalidErr() error { return tgerr.New(500, "CHAT_INVALID") }
func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") }
func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") }
// 文件上传 / 下载相关错误。
func filePartInvalidErr() error { return tgerr.New(400, "FILE_PART_INVALID") }
func filePartsInvalidErr() error { return tgerr.New(400, "FILE_PARTS_INVALID") }
func filePartTooBigErr() error { return tgerr.New(400, "FILE_PART_TOO_BIG") }
func fileReferenceInvalidErr() error { return tgerr.New(400, "FILE_REFERENCE_INVALID") }
func locationInvalidErr() error { return tgerr.New(400, "LOCATION_INVALID") }
func fileIDInvalidErr() error { return tgerr.New(400, "FILE_ID_INVALID") }
func mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") }
func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") }
func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") }
func mediaCaptionTooLongErr() error { return tgerr.New(400, "MEDIA_CAPTION_TOO_LONG") }
func replyMarkupInvalidErr() error { return tgerr.New(400, "REPLY_MARKUP_INVALID") }
func shortcutInvalidErr() error { return tgerr.New(400, "SHORTCUT_INVALID") }
func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") }
func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") }
func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }
func replyToMonoforumPeerInvalidErr() error { return tgerr.New(400, "REPLY_TO_MONOFORUM_PEER_INVALID") }
func optionsTooMuchErr() error { return tgerr.New(400, "OPTIONS_TOO_MUCH") }
func optionInvalidErr() error { return tgerr.New(400, "OPTION_INVALID") }
func pollOptionInvalidErr() error { return tgerr.New(400, "POLL_OPTION_INVALID") }
func pollAnswerInvalidErr() error { return tgerr.New(400, "POLL_ANSWER_INVALID") }
func reactionInvalidErr() error { return tgerr.New(400, "REACTION_INVALID") }
func todoItemsEmptyErr() error { return tgerr.New(400, "TODO_ITEMS_EMPTY") }
func todoNotModifiedErr() error { return tgerr.New(400, "TODO_NOT_MODIFIED") }
func searchQueryEmptyErr() error { return tgerr.New(400, "SEARCH_QUERY_EMPTY") }
func queryTooShortErr() error { return tgerr.New(400, "QUERY_TOO_SHORT") }
func usernameInvalidErr() error { return tgerr.New(400, "USERNAME_INVALID") }
func usernameOccupiedErr() error { return tgerr.New(400, "USERNAME_OCCUPIED") }
func usernameNotOccupiedErr() error { return tgerr.New(400, "USERNAME_NOT_OCCUPIED") }
func usernameNotModifiedErr() error { return tgerr.New(400, "USERNAME_NOT_MODIFIED") }
func phoneNotOccupiedErr() error { return tgerr.New(400, "PHONE_NOT_OCCUPIED") }
func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") }
func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") }
func firstNameInvalidErr() error { return tgerr.New(400, "FIRSTNAME_INVALID") }
func aboutTooLongErr() error { return tgerr.New(400, "ABOUT_TOO_LONG") }
func contactIDInvalidErr() error { return tgerr.New(400, "CONTACT_ID_INVALID") }
func contactNameEmptyErr() error { return tgerr.New(400, "CONTACT_NAME_EMPTY") }
// messageEmptyErr 表示发送空文本。
func messageEmptyErr() error { return tgerr.New(400, "MESSAGE_EMPTY") }
// messageTooLongErr 表示文本超出当前阶段限制。
func messageTooLongErr() error { return tgerr.New(400, "MESSAGE_TOO_LONG") }
func messageIDInvalidErr() error { return tgerr.New(400, "MESSAGE_ID_INVALID") }
func msgIDInvalidErr() error { return tgerr.New(400, "MSG_ID_INVALID") }
func messageAuthorRequiredErr() error { return tgerr.New(403, "MESSAGE_AUTHOR_REQUIRED") }
func messageNotModifiedErr() error { return tgerr.New(400, "MESSAGE_NOT_MODIFIED") }
func messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") }
func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") }
func chatForwardsRestrictedErr() error { return tgerr.New(400, "CHAT_FORWARDS_RESTRICTED") }
func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVALID") }
func persistentTimestampInvalidErr() error { return tgerr.New(400, "PERSISTENT_TIMESTAMP_INVALID") }
func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSING") }
func topicTitleEmptyErr() error { return tgerr.New(400, "TOPIC_TITLE_EMPTY") }
func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") }
// randomIDEmptyErr 表示发送消息缺少 random_id。
func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") }
// scheduleDateInvalidErr 表示当前阶段不支持定时消息。
func scheduleDateInvalidErr() error { return tgerr.New(400, "SCHEDULE_DATE_INVALID") }
// floodWaitErr 表示触发写操作限流。
func floodWaitErr(seconds int) error {
if seconds <= 0 {
seconds = 1
}
return tgerr.New(420, fmt.Sprintf("FLOOD_WAIT_%d", seconds))
}
// signInErr 把登录业务错误映射为客户端可识别的 rpc_error。
func signInErr(err error) error {
switch {
case errors.Is(err, auth.ErrCodeInvalid):
return tgerr.New(400, "PHONE_CODE_INVALID")
case errors.Is(err, auth.ErrCodeExpired):
return tgerr.New(400, "PHONE_CODE_EXPIRED")
case errors.Is(err, domain.ErrFirstNameInvalid):
return firstNameInvalidErr()
default:
return internalErr()
}
}
// bindTempAuthKeyErr 映射 PFS temp auth key 绑定错误。
func bindTempAuthKeyErr(err error) error {
switch {
case errors.Is(err, auth.ErrEncryptedMessageInvalid):
return tgerr.New(400, "ENCRYPTED_MESSAGE_INVALID")
default:
return internalErr()
}
}

67
internal/rpc/folders.go Normal file
View file

@ -0,0 +1,67 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) registerFolders(d *tg.ServerDispatcher) {
d.OnFoldersEditPeerFolders(r.onFoldersEditPeerFolders)
}
func (r *Router) onFoldersEditPeerFolders(ctx context.Context, folderPeers []tg.InputFolderPeer) (tg.UpdatesClass, error) {
if len(folderPeers) > maxDialogInputPeers {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
updates := make([]domain.FolderPeerUpdate, 0, len(folderPeers))
seen := make(map[domain.Peer]struct{}, len(folderPeers))
for _, item := range folderPeers {
if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID {
return nil, folderIDInvalidErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, item.Peer)
if err != nil {
return nil, err
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
updates = append(updates, domain.FolderPeerUpdate{Peer: peer, FolderID: item.FolderID})
}
if len(updates) == 0 {
return &tg.Updates{Date: int(r.clock.Now().Unix()), Seq: 0}, nil
}
if r.deps.Dialogs != nil {
if err := r.deps.Dialogs.EditPeerFolders(ctx, userID, updates); err != nil {
return nil, internalErr()
}
}
event := domain.UpdateEvent{
Type: domain.UpdateEventFolderPeers,
FolderPeers: updates,
PtsCount: 1,
Date: int(r.clock.Now().Unix()),
}
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordFolderPeers(ctx, authKeyID, userID, updates, sessionID)
if err != nil {
return nil, internalErr()
}
}
out := tgUpdateForOutboxEvent(event)
if out == nil {
out = &tg.Updates{Date: event.Date, Seq: 0}
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
return out, nil
}

63
internal/rpc/help.go Normal file
View file

@ -0,0 +1,63 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
)
// registerHelp 注册 help.* RPC handlerDC 配置、最近 DC
func (r *Router) registerHelp(d *tg.ServerDispatcher) {
d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) {
return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now()), nil
})
d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) {
return tdesktop.NearestDC(r.cfg.DC), nil
})
d.OnHelpGetAppConfig(func(ctx context.Context, hash int) (tg.HelpAppConfigClass, error) {
if r.deps.Help == nil {
return tdesktop.AppConfig(hash), nil
}
cfg, notModified, err := r.deps.Help.GetAppConfig(ctx, hash)
if err != nil {
return nil, internalErr()
}
if notModified {
return &tg.HelpAppConfigNotModified{}, nil
}
return &tg.HelpAppConfig{Hash: cfg.Hash, Config: tgJSONValue(cfg.JSON)}, nil
})
d.OnHelpGetCountriesList(func(ctx context.Context, req *tg.HelpGetCountriesListRequest) (tg.HelpCountriesListClass, error) {
if r.deps.Help == nil {
return tdesktop.CountriesList(req.Hash), nil
}
list, notModified, err := r.deps.Help.GetCountries(ctx, req.LangCode, req.Hash)
if err != nil {
return nil, internalErr()
}
if notModified {
return &tg.HelpCountriesListNotModified{}, nil
}
return tgCountriesList(list), nil
})
d.OnHelpGetTimezonesList(func(ctx context.Context, hash int) (tg.HelpTimezonesListClass, error) {
return tdesktop.TimezonesList(hash), nil
})
d.OnHelpGetPeerColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) {
return tdesktop.PeerColors(), nil
})
d.OnHelpGetPeerProfileColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) {
return tdesktop.PeerColors(), nil
})
d.OnHelpGetPromoData(func(ctx context.Context) (tg.HelpPromoDataClass, error) {
return tdesktop.PromoData(r.clock.Now()), nil
})
d.OnHelpGetTermsOfServiceUpdate(func(ctx context.Context) (tg.HelpTermsOfServiceUpdateClass, error) {
return tdesktop.TermsOfServiceUpdate(r.clock.Now()), nil
})
d.OnHelpGetPremiumPromo(func(ctx context.Context) (*tg.HelpPremiumPromo, error) {
return tdesktop.PremiumPromo(), nil
})
}

41
internal/rpc/langpack.go Normal file
View file

@ -0,0 +1,41 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
)
// registerLangpack 注册 langpack.* RPC handler。
func (r *Router) registerLangpack(d *tg.ServerDispatcher) {
d.OnLangpackGetLangPack(func(ctx context.Context, req *tg.LangpackGetLangPackRequest) (*tg.LangPackDifference, error) {
if r.deps.LangPack == nil {
return &tg.LangPackDifference{LangCode: req.LangCode}, nil
}
pack, err := r.deps.LangPack.GetLangPack(ctx, req.LangPack, req.LangCode)
if err != nil {
return nil, internalErr()
}
return tgLangPackDifference(pack), nil
})
d.OnLangpackGetDifference(func(ctx context.Context, req *tg.LangpackGetDifferenceRequest) (*tg.LangPackDifference, error) {
if r.deps.LangPack == nil {
return &tg.LangPackDifference{LangCode: req.LangCode, FromVersion: req.FromVersion}, nil
}
pack, err := r.deps.LangPack.GetDifference(ctx, req.LangPack, req.LangCode, req.FromVersion)
if err != nil {
return nil, internalErr()
}
return tgLangPackDifference(pack), nil
})
d.OnLangpackGetStrings(func(ctx context.Context, req *tg.LangpackGetStringsRequest) ([]tg.LangPackStringClass, error) {
if r.deps.LangPack == nil {
return nil, nil
}
pack, err := r.deps.LangPack.GetStrings(ctx, req.LangPack, req.LangCode, req.Keys)
if err != nil {
return nil, internalErr()
}
return tgLangPackStrings(pack.Strings), nil
})
}

6365
internal/rpc/messages.go Normal file

File diff suppressed because it is too large Load diff

25
internal/rpc/metrics.go Normal file
View file

@ -0,0 +1,25 @@
package rpc
import "time"
// Metrics 接收 RPC 业务层指标。默认 NopMetrics后续可对接 Prometheus。
type Metrics interface {
MessageSend(d time.Duration, duplicate bool, err error)
MessageRateLimited(retryAfterSeconds int)
OutboxClaimed(count int)
OutboxDelivered(d time.Duration)
OutboxFailed(err error)
}
// NopMetrics 是 Metrics 的空实现。
type NopMetrics struct{}
func (NopMetrics) MessageSend(time.Duration, bool, error) {}
func (NopMetrics) MessageRateLimited(int) {}
func (NopMetrics) OutboxClaimed(int) {}
func (NopMetrics) OutboxDelivered(time.Duration) {}
func (NopMetrics) OutboxFailed(error) {}

View file

@ -0,0 +1,369 @@
package rpc
import (
"context"
"errors"
"sync"
"time"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
defaultOutboxBatch = 100
defaultOutboxInterval = 200 * time.Millisecond
defaultOutboxWorkers = 2
)
var errMissingOutboxEvent = errors.New("missing outbox update event")
// OutboxDispatcher 把 PG transactional outbox 中的 update 批量推给在线 session。
// 多 worker 并发 claimClaimPending 用 FOR UPDATE SKIP LOCKEDworker 间认领不重叠。
type OutboxDispatcher struct {
events store.UpdateEventStore
outbox store.DispatchOutboxStore
sessions SessionBinder
log *zap.Logger
metrics Metrics
batch int
interval time.Duration
workers int
pushTimeout time.Duration
}
// OutboxOption 调整 OutboxDispatcher 的运行参数。
type OutboxOption func(*OutboxDispatcher)
// WithOutboxBatch 设置每次 claim 的最大条数;<=0 时保持默认。
func WithOutboxBatch(n int) OutboxOption {
return func(d *OutboxDispatcher) {
if n > 0 {
d.batch = n
}
}
}
// WithOutboxInterval 设置两次 claim 之间的轮询间隔;<=0 时保持默认。
func WithOutboxInterval(interval time.Duration) OutboxOption {
return func(d *OutboxDispatcher) {
if interval > 0 {
d.interval = interval
}
}
}
// WithOutboxWorkers 设置并发 claim worker 数;<=0 时保持默认。
func WithOutboxWorkers(n int) OutboxOption {
return func(d *OutboxDispatcher) {
if n > 0 {
d.workers = n
}
}
}
// WithOutboxPushTimeout 设置 updates fanout 入队等待时间;<=0 时使用同步可靠推送。
func WithOutboxPushTimeout(timeout time.Duration) OutboxOption {
return func(d *OutboxDispatcher) {
if timeout > 0 {
d.pushTimeout = timeout
}
}
}
// WithOutboxMetrics 注入指标实现nil 时保持 NopMetrics。
func WithOutboxMetrics(m Metrics) OutboxOption {
return func(d *OutboxDispatcher) {
if m != nil {
d.metrics = m
}
}
}
// NewOutboxDispatcher 创建在线 update 推送 worker。batch/interval 默认值见
// defaultOutboxBatch/defaultOutboxInterval可经 WithOutbox* 选项覆盖(生产由 config 注入)。
func NewOutboxDispatcher(events store.UpdateEventStore, outbox store.DispatchOutboxStore, sessions SessionBinder, log *zap.Logger, opts ...OutboxOption) *OutboxDispatcher {
if log == nil {
log = zap.NewNop()
}
d := &OutboxDispatcher{
events: events,
outbox: outbox,
sessions: sessions,
log: log,
metrics: NopMetrics{},
batch: defaultOutboxBatch,
interval: defaultOutboxInterval,
workers: defaultOutboxWorkers,
}
for _, opt := range opts {
if opt != nil {
opt(d)
}
}
return d
}
// Run 启动 workers 个并发 worker 持续 claim pending outboxctx 退出时全部停止并等待退出。
func (d *OutboxDispatcher) Run(ctx context.Context) {
if d == nil || d.events == nil || d.outbox == nil || d.sessions == nil {
return
}
workers := d.workers
if workers < 1 {
workers = 1
}
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
d.runWorker(ctx)
}()
}
wg.Wait()
}
// runWorker 是单个 claim 循环;多 worker 靠 ClaimPending 的 SKIP LOCKED 互不重叠。
func (d *OutboxDispatcher) runWorker(ctx context.Context) {
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
d.DispatchOnce(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// batchEventLoader 是 UpdateEventStore 的可选批量能力:一次取多条 (user,pts) 事件。
type batchEventLoader interface {
BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error)
}
// batchOutboxMarker 是 DispatchOutboxStore 的可选批量能力:一次标记多行 delivered。
type batchOutboxMarker interface {
MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error
}
// DispatchOnce claim 一批 outbox 并投递,测试可直接调用。
// store 同时具备批量取事件 + 批量标记能力时走批量路径(每批 ~3 次 PG 往返),否则逐条回退。
func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) {
items, err := d.outbox.ClaimPending(ctx, d.batch)
if err != nil {
d.log.Warn("claim dispatch outbox", zap.Error(err))
return
}
if len(items) == 0 {
return
}
d.metrics.OutboxClaimed(len(items))
if loader, ok := d.events.(batchEventLoader); ok {
if marker, ok := d.outbox.(batchOutboxMarker); ok {
d.dispatchBatch(ctx, items, loader, marker)
return
}
}
for _, item := range items {
d.dispatchItem(ctx, item)
}
}
type outboxEventKey struct {
userID int64
pts int
}
// dispatchBatch 批量加载已 claim 事件、逐条 push、批量标记 delivered失败项单独退避重试。
func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.DispatchOutboxItem, loader batchEventLoader, marker batchOutboxMarker) {
cursors := make([]store.EventCursor, len(items))
for i, item := range items {
cursors[i] = store.EventCursor{UserID: item.TargetUserID, Pts: item.Pts}
}
events, err := loader.BatchByCursor(ctx, cursors)
if err != nil {
// 批量取失败则整批回退逐条路径,让每条各自重试/标失败,不丢进度。
d.log.Warn("batch load dispatch events", zap.Error(err))
for _, item := range items {
d.dispatchItem(ctx, item)
}
return
}
byKey := make(map[outboxEventKey]domain.UpdateEvent, len(events))
for _, event := range events {
byKey[outboxEventKey{event.UserID, event.Pts}] = event
}
start := time.Now()
delivered := make([]store.DispatchOutboxItem, 0, len(items))
for _, item := range items {
event, ok := byKey[outboxEventKey{item.TargetUserID, item.Pts}]
if !ok {
d.markDispatchFailed(ctx, item, errMissingOutboxEvent)
continue
}
update := tgUpdateForOutboxEvent(event)
if update == nil {
delivered = append(delivered, item)
continue
}
if _, retriable, err := d.pushOutboxUpdate(ctx, item, update); err != nil {
if retriable {
// 出站队列拥塞:留 dispatching 行靠租约过期重投,不计入 attempts 升级。
// 不加入 delivered故不会被 MarkDeliveredBatch 删除。
continue
}
d.markDispatchFailed(ctx, item, err)
continue
}
delivered = append(delivered, item)
}
if len(delivered) == 0 {
return
}
if err := marker.MarkDeliveredBatch(ctx, delivered); err != nil {
// 批量标记失败则逐条标记,避免整批已投递却卡在 dispatching 等租约过期重投。
d.log.Warn("mark dispatch delivered batch", zap.Error(err))
for _, item := range delivered {
if markErr := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); markErr != nil {
d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(markErr))
}
}
}
per := time.Since(start) / time.Duration(len(delivered))
for range delivered {
d.metrics.OutboxDelivered(per)
}
}
func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.DispatchOutboxItem) {
start := time.Now()
events, err := d.events.ListAfter(ctx, item.TargetUserID, item.Pts-1, 1)
if err != nil {
d.markDispatchFailed(ctx, item, err)
return
}
if len(events) == 0 || events[0].Pts != item.Pts {
d.markDispatchFailed(ctx, item, errMissingOutboxEvent)
return
}
update := tgUpdateForOutboxEvent(events[0])
if update == nil {
if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil {
d.log.Warn("mark noop dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err))
return
}
d.metrics.OutboxDelivered(time.Since(start))
return
}
sent, retriable, err := d.pushOutboxUpdate(ctx, item, update)
if err != nil {
if retriable {
// 出站队列拥塞:保留 dispatching 行靠租约过期defaultDispatchLease重新 claim 重投,
// 不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed。
d.log.Debug("dispatch outbox deferred (push queue full)",
zap.Int64("target_user_id", item.TargetUserID),
zap.Int64("outbox_id", item.ID),
zap.Int("pts", item.Pts),
)
return
}
d.markDispatchFailed(ctx, item, err)
return
}
if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil {
d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err))
return
}
d.metrics.OutboxDelivered(time.Since(start))
d.log.Debug("dispatch outbox delivered",
zap.Int64("target_user_id", item.TargetUserID),
zap.Int64("outbox_id", item.ID),
zap.Int("pts", item.Pts),
zap.Int("sessions", sent),
)
}
// pushOutboxUpdate 投递一条 outbox update返回 (送达的在线 session 数, 是否可重试, err)。
// best-effort 路径pushTimeout>0的失败只可能是出站队列拥塞慢消费者入队超时属暂时性、
// 可重试:调用方应保留 dispatching 行靠租约过期重投,而非计入 attempts 升级为 failed。
// 可靠路径的失败是真实投递错误retriable=false按原逻辑退避升级。
func (d *OutboxDispatcher) pushOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, update *tg.Updates) (sent int, retriable bool, err error) {
var zeroAuthKeyID [8]byte
if d.pushTimeout > 0 {
if scoped, ok := d.sessions.(ScopedBestEffortSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID {
sent, err = scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout)
return sent, err != nil, err
}
if bestEffort, ok := d.sessions.(BestEffortSessionBinder); ok {
sent, err = bestEffort.PushToUserExceptSessionBestEffort(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout)
return sent, err != nil, err
}
}
if scoped, ok := d.sessions.(ScopedSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID {
sent, err = scoped.PushToUserExceptAuthKeySession(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update)
return sent, false, err
}
sent, err = d.sessions.PushToUserExceptSession(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update)
return sent, false, err
}
func (d *OutboxDispatcher) markDispatchFailed(ctx context.Context, item store.DispatchOutboxItem, err error) {
if err == nil {
err = errMissingOutboxEvent
}
if markErr := d.outbox.MarkFailed(ctx, item.TargetUserID, item.ID, err.Error()); markErr != nil {
d.log.Warn("mark dispatch failed",
zap.Int64("target_user_id", item.TargetUserID),
zap.Int64("outbox_id", item.ID),
zap.Error(markErr),
)
return
}
d.metrics.OutboxFailed(err)
d.log.Debug("dispatch outbox failed",
zap.Int64("target_user_id", item.TargetUserID),
zap.Int64("outbox_id", item.ID),
zap.Int("pts", item.Pts),
zap.Error(err),
)
}
func tgUpdateForOutboxEvent(event domain.UpdateEvent) *tg.Updates {
switch event.Type {
case domain.UpdateEventNewMessage:
return tgPrivateMessageUpdates(event, event.Message, 0, false, tgUsers(event.Users), tgChannels(event.UserID, event.Channels))
case domain.UpdateEventReadHistoryInbox, domain.UpdateEventReadHistoryOutbox:
var update tg.UpdateClass
if event.Type == domain.UpdateEventReadHistoryOutbox {
update = tgReadHistoryOutboxUpdate(event)
} else {
update = tgReadHistoryInboxUpdate(event)
}
if update == nil {
return nil
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: event.Date,
Seq: 0, // 私聊不维护账号级 seq恒 0
}
case domain.UpdateEventNoop:
return nil
default:
update := tgOtherUpdateFromEvent(event)
if update == nil {
return nil
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: event.Date,
Seq: 0,
}
}
}

View file

@ -0,0 +1,458 @@
package rpc
import (
"context"
"errors"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestOutboxDispatcherPushesNewMessageAndMarksDelivered(t *testing.T) {
msg := domain.Message{
ID: 10,
OwnerUserID: 1000000002,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
Date: 1700000300,
Body: "hello",
Pts: 7,
}
outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
ID: 55,
TargetUserID: msg.OwnerUserID,
Pts: msg.Pts,
EventType: domain.UpdateEventNewMessage,
ExcludeSessionID: 99,
}}}
events := &captureUpdateEventStore{events: []domain.UpdateEvent{{
UserID: msg.OwnerUserID,
Type: domain.UpdateEventNewMessage,
Pts: msg.Pts,
PtsCount: 1,
Date: msg.Date,
Message: msg,
Users: []domain.User{{
ID: msg.From.ID,
FirstName: "Sender",
}},
}}}
sessions := &captureSessions{}
metrics := &captureOutboxMetrics{}
dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics))
dispatcher.DispatchOnce(context.Background())
if !outbox.delivered || outbox.deliveredUserID != msg.OwnerUserID || outbox.deliveredID != 55 {
t.Fatalf("delivered = %v user=%d id=%d, want outbox delivered", outbox.delivered, outbox.deliveredUserID, outbox.deliveredID)
}
if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 || sessions.messageType != proto.MessageFromServer {
t.Fatalf("push target = user %d exclude %d type %v, want outbox target/exclude", sessions.userID, sessions.sessionID, sessions.messageType)
}
updates, ok := sessions.message.(*tg.Updates)
if !ok {
t.Fatalf("pushed message = %T, want *tg.Updates", sessions.message)
}
if len(updates.Updates) != 1 || len(updates.Users) != 1 {
t.Fatalf("updates = %+v, want one update and sender user", updates)
}
update, ok := updates.Updates[0].(*tg.UpdateNewMessage)
if !ok || update.Pts != msg.Pts {
t.Fatalf("update = %#v, want UpdateNewMessage pts=%d", updates.Updates[0], msg.Pts)
}
if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 {
t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed)
}
}
func TestOutboxDispatcherUsesScopedAuthKeyExclusion(t *testing.T) {
var excludeAuthKeyID [8]byte
excludeAuthKeyID[0] = 7
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}
outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
ID: 57,
TargetUserID: 1000000002,
Pts: 9,
EventType: domain.UpdateEventPeerSettings,
ExcludeAuthKeyID: excludeAuthKeyID,
ExcludeSessionID: 99,
}}}
events := &captureUpdateEventStore{events: []domain.UpdateEvent{{
UserID: 1000000002,
Type: domain.UpdateEventPeerSettings,
Pts: 9,
PtsCount: 1,
Date: 1700000302,
Peer: peer,
}}}
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t))
dispatcher.DispatchOnce(context.Background())
if sessions.scopedAuthKeyID != excludeAuthKeyID || sessions.sessionID != 99 || sessions.userID != 1000000002 {
t.Fatalf("scoped push = auth %x session %d user %d, want precise outbox exclusion", sessions.scopedAuthKeyID, sessions.sessionID, sessions.userID)
}
}
// TestOutboxDispatcherBatchPath 覆盖生产批量路径store 同时具备 BatchByCursor + MarkDeliveredBatch
// 时DispatchOnce 一次批量取事件、推送、再批量标记 delivered而非逐条。
func TestOutboxDispatcherBatchPath(t *testing.T) {
msg := domain.Message{
ID: 10,
OwnerUserID: 1000000002,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
Date: 1700000300,
Body: "hello",
Pts: 7,
}
events := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: []domain.UpdateEvent{{
UserID: msg.OwnerUserID,
Type: domain.UpdateEventNewMessage,
Pts: msg.Pts,
PtsCount: 1,
Date: msg.Date,
Message: msg,
Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}},
}}}}
outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
ID: 55,
TargetUserID: msg.OwnerUserID,
Pts: msg.Pts,
EventType: domain.UpdateEventNewMessage,
ExcludeSessionID: 99,
}}}}
sessions := &captureSessions{}
metrics := &captureOutboxMetrics{}
dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics))
dispatcher.DispatchOnce(context.Background())
if len(events.batchCursors) != 1 || events.batchCursors[0] != (store.EventCursor{UserID: msg.OwnerUserID, Pts: msg.Pts}) {
t.Fatalf("batch cursors = %+v, want one cursor for (%d,%d)", events.batchCursors, msg.OwnerUserID, msg.Pts)
}
if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 {
t.Fatalf("push target = user %d exclude %d, want batch push to outbox target", sessions.userID, sessions.sessionID)
}
if len(outbox.deliveredBatch) != 1 || outbox.deliveredBatch[0].ID != 55 {
t.Fatalf("delivered batch = %+v, want one item id=55", outbox.deliveredBatch)
}
if outbox.delivered {
t.Fatalf("batch path should not call per-item MarkDelivered")
}
if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 {
t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed)
}
}
func TestOutboxDispatcherUsesBestEffortPush(t *testing.T) {
msg := domain.Message{
ID: 10,
OwnerUserID: 1000000002,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
Date: 1700000300,
Body: "hello",
Pts: 7,
}
events := &captureUpdateEventStore{events: []domain.UpdateEvent{{
UserID: msg.OwnerUserID,
Type: domain.UpdateEventNewMessage,
Pts: msg.Pts,
PtsCount: 1,
Date: msg.Date,
Message: msg,
Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}},
}}}
outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
ID: 55,
TargetUserID: msg.OwnerUserID,
Pts: msg.Pts,
EventType: domain.UpdateEventNewMessage,
ExcludeSessionID: 99,
}}}
sessions := &captureBestEffortSessions{captureSessions: &captureSessions{}}
dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond))
dispatcher.DispatchOnce(context.Background())
if !sessions.bestEffort || sessions.timeout != 50*time.Millisecond {
t.Fatalf("best-effort push = %v timeout %v, want true/50ms", sessions.bestEffort, sessions.timeout)
}
if !outbox.delivered || outbox.failed {
t.Fatalf("outbox delivered=%v failed=%v, want delivered after accepted best-effort push", outbox.delivered, outbox.failed)
}
}
type captureBestEffortSessions struct {
*captureSessions
bestEffort bool
timeout time.Duration
}
func (s *captureBestEffortSessions) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) {
s.bestEffort = true
s.timeout = timeout
return s.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg)
}
// batchEventStore 给 captureUpdateEventStore 加上 BatchByCursor 批量能力。
type batchEventStore struct {
*captureUpdateEventStore
batchCursors []store.EventCursor
}
func (s *batchEventStore) BatchByCursor(_ context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) {
s.batchCursors = cursors
out := make([]domain.UpdateEvent, 0, len(cursors))
for _, c := range cursors {
for _, event := range s.events {
if event.UserID == c.UserID && event.Pts == c.Pts {
out = append(out, event)
}
}
}
return out, nil
}
// batchDispatchOutbox 给 captureDispatchOutbox 加上 MarkDeliveredBatch 批量能力。
type batchDispatchOutbox struct {
*captureDispatchOutbox
deliveredBatch []store.DispatchOutboxItem
}
func (s *batchDispatchOutbox) MarkDeliveredBatch(_ context.Context, items []store.DispatchOutboxItem) error {
s.deliveredBatch = append(s.deliveredBatch, items...)
return nil
}
type captureUpdateEventStore struct {
events []domain.UpdateEvent
}
func (s *captureUpdateEventStore) Append(context.Context, int64, domain.UpdateEvent) error {
return nil
}
func (s *captureUpdateEventStore) ListAfter(_ context.Context, _ int64, pts, limit int) ([]domain.UpdateEvent, error) {
out := make([]domain.UpdateEvent, 0, len(s.events))
for _, event := range s.events {
if event.Pts > pts {
out = append(out, event)
if len(out) == limit {
break
}
}
}
return out, nil
}
func (s *captureUpdateEventStore) Current(context.Context, int64) (int, error) {
maxPts := 0
for _, event := range s.events {
if event.Pts > maxPts {
maxPts = event.Pts
}
}
return maxPts, nil
}
func (s *captureUpdateEventStore) MaxContiguousPts(context.Context, int64) (int, error) {
present := make(map[int]struct{}, len(s.events))
for _, event := range s.events {
present[event.Pts] = struct{}{}
}
contiguous := 0
for {
if _, ok := present[contiguous+1]; !ok {
break
}
contiguous++
}
return contiguous, nil
}
func (s *captureUpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) {
return s.MaxContiguousPts(ctx, userID)
}
type captureDispatchOutbox struct {
items []store.DispatchOutboxItem
delivered bool
deliveredUserID int64
deliveredID int64
failed bool
failedError string
}
type captureScopedSessions struct {
*captureSessions
scopedAuthKeyID [8]byte
}
func (s *captureScopedSessions) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) {
s.BindAuthKey(sessionID, authKeyID)
s.scopedAuthKeyID = rawAuthKeyID
}
func (s *captureScopedSessions) AuthKeyIDForSession([8]byte, int64) ([8]byte, bool) {
return s.AuthKeyID(0)
}
func (s *captureScopedSessions) BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) {
s.BindUser(sessionID, userID)
s.scopedAuthKeyID = rawAuthKeyID
}
func (s *captureScopedSessions) UserIDForAuthKey([8]byte, int64) (int64, bool) {
return s.UserID(0)
}
func (s *captureScopedSessions) UserIDResolvedForAuthKey([8]byte, int64) (int64, bool) {
return s.UserIDResolved(0)
}
func (s *captureScopedSessions) SetReceivesUpdatesForAuthKey([8]byte, int64, bool) {}
func (s *captureScopedSessions) PushToSessionForAuthKey(_ context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error {
s.scopedAuthKeyID = rawAuthKeyID
return s.PushToSession(context.Background(), sessionID, t, msg)
}
func (s *captureScopedSessions) PushToUserExceptAuthKeySession(_ context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
s.scopedAuthKeyID = excludeAuthKeyID
return s.PushToUserExceptSession(context.Background(), userID, excludeSessionID, t, msg)
}
func (s *captureDispatchOutbox) ClaimPending(context.Context, int) ([]store.DispatchOutboxItem, error) {
items := s.items
s.items = nil
return items, nil
}
func (s *captureDispatchOutbox) MarkDelivered(_ context.Context, targetUserID, id int64) error {
s.delivered = true
s.deliveredUserID = targetUserID
s.deliveredID = id
return nil
}
func (s *captureDispatchOutbox) MarkFailed(_ context.Context, _ int64, _ int64, lastError string) error {
s.failed = true
s.failedError = lastError
return nil
}
func (s *captureDispatchOutbox) DeleteFailed(context.Context, time.Duration, int) (int, error) {
return 0, nil
}
func TestOutboxDispatcherUsesNoopAsDelivered(t *testing.T) {
outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
ID: 56,
TargetUserID: 1000000002,
Pts: 8,
EventType: domain.UpdateEventNoop,
}}}
events := &captureUpdateEventStore{events: []domain.UpdateEvent{{
UserID: 1000000002,
Type: domain.UpdateEventNoop,
Pts: 8,
Date: 1700000301,
}}}
metrics := &captureOutboxMetrics{}
dispatcher := NewOutboxDispatcher(events, outbox, &captureSessions{}, zaptest.NewLogger(t), WithOutboxMetrics(metrics))
dispatcher.DispatchOnce(context.Background())
if !outbox.delivered || outbox.failed {
t.Fatalf("noop delivered=%v failed=%v, want delivered without push", outbox.delivered, outbox.failed)
}
if metrics.delivered != 1 {
t.Fatalf("noop delivered metrics = %d, want 1", metrics.delivered)
}
}
type captureOutboxMetrics struct {
claimed int
delivered int
failed int
}
func (m *captureOutboxMetrics) MessageSend(time.Duration, bool, error) {}
func (m *captureOutboxMetrics) MessageRateLimited(int) {}
func (m *captureOutboxMetrics) OutboxClaimed(count int) {
m.claimed += count
}
func (m *captureOutboxMetrics) OutboxDelivered(time.Duration) {
m.delivered++
}
func (m *captureOutboxMetrics) OutboxFailed(error) {
m.failed++
}
// queueFullBestEffortSessions 模拟出站队列拥塞best-effort 推送总是失败(入队超时 / 队列满)。
type queueFullBestEffortSessions struct {
*captureSessions
attempts int
}
func (s *queueFullBestEffortSessions) PushToUserExceptSessionBestEffort(_ context.Context, _ int64, _ int64, _ proto.MessageType, _ bin.Encoder, _ time.Duration) (int, error) {
s.attempts++
return 0, errors.New("mtproto outbound queue full")
}
// TestOutboxDispatcherDefersOnPushQueueFull 验证 best-effort 推送因出站队列拥塞失败时dispatcher
// 既不标记 delivered任务保留靠 dispatching 租约过期重投,满足至少一次投递语义),也不标记
// failed拥塞不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed
func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) {
msg := domain.Message{
ID: 10,
OwnerUserID: 1000000002,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
Date: 1700000300,
Body: "hello",
Pts: 7,
}
events := &captureUpdateEventStore{events: []domain.UpdateEvent{{
UserID: msg.OwnerUserID,
Type: domain.UpdateEventNewMessage,
Pts: msg.Pts,
PtsCount: 1,
Date: msg.Date,
Message: msg,
Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}},
}}}
outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
ID: 55,
TargetUserID: msg.OwnerUserID,
Pts: msg.Pts,
EventType: domain.UpdateEventNewMessage,
ExcludeSessionID: 99,
}}}
sessions := &queueFullBestEffortSessions{captureSessions: &captureSessions{}}
metrics := &captureOutboxMetrics{}
dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond), WithOutboxMetrics(metrics))
dispatcher.DispatchOnce(context.Background())
if sessions.attempts != 1 {
t.Fatalf("best-effort push attempts = %d, want 1应走 best-effort 推送路径)", sessions.attempts)
}
if outbox.delivered {
t.Fatalf("outbox delivered=true, want 未投递(拥塞应保留 dispatching 行靠租约重投)")
}
if outbox.failed {
t.Fatalf("outbox failed=true, want 未失败(拥塞不计入 attempts 升级)")
}
if metrics.failed != 0 {
t.Fatalf("metrics.failed=%d, want 0拥塞不算投递失败", metrics.failed)
}
}

22
internal/rpc/payments.go Normal file
View file

@ -0,0 +1,22 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
)
// registerPayments 注册第一阶段 TDesktop 启动所需 payments.* RPC 兼容响应。
func (r *Router) registerPayments(d *tg.ServerDispatcher) {
d.OnPaymentsGetStarGiftActiveAuctions(func(ctx context.Context, hash int64) (tg.PaymentsStarGiftActiveAuctionsClass, error) {
return tdesktop.StarGiftActiveAuctions(), nil
})
d.OnPaymentsGetSavedStarGifts(func(ctx context.Context, req *tg.PaymentsGetSavedStarGiftsRequest) (*tg.PaymentsSavedStarGifts, error) {
return tdesktop.SavedStarGifts(), nil
})
d.OnPaymentsGetSavedStarGift(func(ctx context.Context, stargift []tg.InputSavedStarGiftClass) (*tg.PaymentsSavedStarGifts, error) {
return tdesktop.SavedStarGifts(), nil
})
}

250
internal/rpc/photos.go Normal file
View file

@ -0,0 +1,250 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// registerPhotos 注册 photos.* RPC handler头像上传 / 切换 / 查询 / 删除)。
func (r *Router) registerPhotos(d *tg.ServerDispatcher) {
d.OnPhotosUploadProfilePhoto(r.onPhotosUploadProfilePhoto)
d.OnPhotosUpdateProfilePhoto(r.onPhotosUpdateProfilePhoto)
d.OnPhotosGetUserPhotos(r.onPhotosGetUserPhotos)
d.OnPhotosDeletePhotos(r.onPhotosDeletePhotos)
}
func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosUploadProfilePhotoRequest) (*tg.PhotosPhoto, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
file, hasFile := req.GetFile()
if !hasFile {
// 仅 fallback / video / emoji markup 等本阶段不支持的头像变体。
return nil, photoInvalidErr()
}
ref, ok := uploadedFileRef(userID, file)
if !ok {
return nil, fileReferenceInvalidErr()
}
photo, err := r.deps.Files.UploadProfilePhoto(ctx, domain.PeerTypeUser, userID, ref, int(r.clock.Now().Unix()))
if err != nil {
return nil, photoUploadErr(err)
}
return r.photosPhotoForSelf(ctx, userID, photo), nil
}
func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosUpdateProfilePhotoRequest) (*tg.PhotosPhoto, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
switch in := req.ID.(type) {
case *tg.InputPhoto:
photo, found, err := r.deps.Files.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, userID, in.ID, int(r.clock.Now().Unix()))
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return r.photosPhotoForSelf(ctx, userID, photo), nil
default:
// InputPhotoEmpty移除当前头像停用现有当前照片
if cur, found, err := r.deps.Files.CurrentProfilePhoto(ctx, domain.PeerTypeUser, userID); err == nil && found {
_, _ = r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, []int64{cur.ID})
}
return r.photosPhotoForSelf(ctx, userID, domain.Photo{}), nil
}
}
func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) {
if r.deps.Files == nil {
return &tg.PhotosPhotos{}, nil
}
currentUserID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || currentUserID == 0 {
return nil, userIDInvalidErr()
}
target, found, err := r.userFromInput(ctx, currentUserID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, userIDInvalidErr()
}
offset := req.Offset
if offset < 0 {
offset = 0
}
limit := req.Limit
if limit <= 0 || limit > 100 {
limit = 100
}
photos, total, err := r.deps.Files.GetProfilePhotos(ctx, domain.PeerTypeUser, target.ID, offset, limit, req.MaxID)
if err != nil {
return nil, internalErr()
}
tgPhotos := make([]tg.PhotoClass, 0, len(photos))
for _, p := range photos {
tgPhotos = append(tgPhotos, tgPhoto(p))
}
users := []tg.UserClass{r.tgUser(target)}
if total > len(photos)+offset {
return &tg.PhotosPhotosSlice{Count: total, Photos: tgPhotos, Users: users}, nil
}
return &tg.PhotosPhotos{Photos: tgPhotos, Users: users}, nil
}
func (r *Router) onPhotosDeletePhotos(ctx context.Context, id []tg.InputPhotoClass) ([]int64, error) {
if r.deps.Files == nil {
return []int64{}, nil
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
ids := make([]int64, 0, len(id))
for _, in := range id {
if photo, isPhoto := in.(*tg.InputPhoto); isPhoto && photo.ID != 0 {
ids = append(ids, photo.ID)
}
}
if len(ids) == 0 {
return []int64{}, nil
}
if _, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids); err != nil {
return nil, internalErr()
}
return ids, nil
}
// photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user并在头像变更后
// 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto
// 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。
func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo) *tg.PhotosPhoto {
out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}}
if r.deps.Users == nil {
return out
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return out
}
out.Users = append(out.Users, r.tgSelfUser(self))
r.pushSelfPhotoUpdate(ctx, self)
return out
}
// pushSelfPhotoUpdate 向该账号其它在线设备推送头像变更。updateUserName 不含 photo 无法刷新
// 头像updateUser 只是「该 user 变了」的信号TDesktop 仅当 peer 已 full-loaded 时才
// forceFull 重拉);最可靠是在 Updates.Users 带上含新 userProfilePhoto 的完整 self user
// TDesktop 经 processUser→setPhoto→peerUpdated(Photo) 即时刷新。当前设备同时经 RPC 返回更新,
// 重复推送对 TDesktop 幂等setUserpicChecked 比对 photo_id相同则 no-op
func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) {
if self.ID == 0 {
return
}
r.pushUserUpdates(ctx, self.ID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}},
Users: []tg.UserClass{r.tgSelfUser(self)},
Date: int(r.clock.Now().Unix()),
})
}
// uploadedFileRef 把 tg.InputFile / InputFileBig 转成 domain.UploadedFileRef。
func uploadedFileRef(ownerUserID int64, file tg.InputFileClass) (domain.UploadedFileRef, bool) {
switch f := file.(type) {
case *tg.InputFile:
if f.ID == 0 || f.Parts <= 0 {
return domain.UploadedFileRef{}, false
}
return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, MD5: f.MD5Checksum}, true
case *tg.InputFileBig:
if f.ID == 0 || f.Parts <= 0 {
return domain.UploadedFileRef{}, false
}
return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, Big: true}, true
default:
return domain.UploadedFileRef{}, false
}
}
// resolveInputChatPhoto 把 tg.InputChatPhoto 解析为 *domain.Photonil=清除头像)。
// 支持新上传InputChatUploadedPhoto与引用已有照片InputChatPhoto{InputPhoto})。
func (r *Router) resolveInputChatPhoto(ctx context.Context, userID int64, input tg.InputChatPhotoClass) (*domain.Photo, error) {
switch in := input.(type) {
case *tg.InputChatPhotoEmpty:
return nil, nil
case *tg.InputChatUploadedPhoto:
file, ok := in.GetFile()
if !ok {
return nil, photoInvalidErr()
}
if r.deps.Files == nil {
return nil, photoInvalidErr()
}
ref, ok := uploadedFileRef(userID, file)
if !ok {
return nil, fileReferenceInvalidErr()
}
// 频道/群头像用 avatar 尺寸('a'/'c'),匹配 InputPeerPhotoFileLocation 下载路径。
photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref)
if err != nil {
return nil, photoUploadErr(err)
}
return &photo, nil
case *tg.InputChatPhoto:
switch id := in.ID.(type) {
case *tg.InputPhoto:
if r.deps.Files == nil {
return nil, photoInvalidErr()
}
photo, found, err := r.deps.Files.GetPhoto(ctx, id.ID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return &photo, nil
default:
return nil, nil // InputPhotoEmpty → 清除
}
default:
return nil, photoInvalidErr()
}
}
func photoUploadErr(err error) error {
switch {
case errors.Is(err, domain.ErrFilePartsInvalid):
return filePartsInvalidErr()
case errors.Is(err, domain.ErrPhotoInvalid):
return photoInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,58 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestUploadProfilePhotoPushesUpdateToOtherDevices 守护审计修复:换头像后必须向该账号其它在线
// 设备推送updateUser 信号 + Updates.Users 带新 self user否则其它设备头像不刷新。
// 原因updateUserName 不含 photo 无法刷新头像,唯有带 user 对象最可靠(见 photos.go
// pushSelfPhotoUpdate 注释)。曾完全不推送,本测试防回归。
func TestUploadProfilePhotoPushesUpdateToOtherDevices(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"})
sessions := &captureSessions{}
files := &fakeFiles{}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Files: files,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
req := &tg.PhotosUploadProfilePhotoRequest{}
req.SetFile(&tg.InputFile{ID: 42, Parts: 1, Name: "a.jpg"}) // File 是 flags 可选字段,须 SetFile 置位
if _, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), req); err != nil {
t.Fatalf("uploadProfilePhoto: %v", err)
}
snap := sessions.snapshot()
if snap.userID != owner.ID {
t.Fatalf("push target 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)
}
hasUserUpdate := false
for _, u := range updates.Updates {
if uu, ok := u.(*tg.UpdateUser); ok && uu.UserID == owner.ID {
hasUserUpdate = true
}
}
if !hasUserUpdate {
t.Fatalf("updates = %+v, want UpdateUser for self", updates.Updates)
}
if len(updates.Users) == 0 {
t.Fatal("pushed updates missing self user — other devices cannot refresh avatar")
}
}

142
internal/rpc/premium.go Normal file
View file

@ -0,0 +1,142 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
const (
maxPremiumBoostsListLimit = 100
maxPremiumBoostsOffsetBytes = 128
maxPremiumApplyBoostSlots = 16
)
func (r *Router) registerPremium(d *tg.ServerDispatcher) {
d.OnPremiumGetBoostsStatus(r.onPremiumGetBoostsStatus)
d.OnPremiumGetBoostsList(r.onPremiumGetBoostsList)
d.OnPremiumGetMyBoosts(r.onPremiumGetMyBoosts)
d.OnPremiumApplyBoost(r.onPremiumApplyBoost)
d.OnPremiumGetUserBoosts(r.onPremiumGetUserBoosts)
}
func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeerClass) (*tg.PremiumBoostsStatus, error) {
if _, _, err := r.premiumBoostChannelView(ctx, peer, false); err != nil {
return nil, err
}
return emptyPremiumBoostsStatus(), nil
}
func (r *Router) onPremiumGetBoostsList(ctx context.Context, req *tg.PremiumGetBoostsListRequest) (*tg.PremiumBoostsList, error) {
if req.Limit < 0 || req.Limit > maxPremiumBoostsListLimit || len(req.Offset) > maxPremiumBoostsOffsetBytes {
return nil, limitInvalidErr()
}
if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, true); err != nil {
return nil, err
}
return emptyPremiumBoostsList(), nil
}
func (r *Router) onPremiumGetMyBoosts(ctx context.Context) (*tg.PremiumMyBoosts, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return emptyPremiumMyBoosts(), nil
}
func (r *Router) onPremiumApplyBoost(ctx context.Context, req *tg.PremiumApplyBoostRequest) (*tg.PremiumMyBoosts, error) {
slots, ok := req.GetSlots()
if !ok {
return nil, tgerr400("BOOSTS_EMPTY")
}
if len(slots) == 0 {
return nil, tgerr400("SLOTS_EMPTY")
}
if len(slots) > maxPremiumApplyBoostSlots {
return nil, limitInvalidErr()
}
for _, slot := range slots {
if slot < 0 {
return nil, tgerr400("SLOTS_INVALID")
}
}
if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, false); err != nil {
return nil, err
}
return emptyPremiumMyBoosts(), nil
}
func (r *Router) onPremiumGetUserBoosts(ctx context.Context, req *tg.PremiumGetUserBoostsRequest) (*tg.PremiumBoostsList, error) {
userID, view, err := r.premiumBoostChannelView(ctx, req.Peer, true)
if err != nil {
return nil, err
}
if _, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}); err != nil {
return nil, err
}
_ = view
return emptyPremiumBoostsList(), nil
}
func (r *Router) premiumBoostChannelView(ctx context.Context, peer tg.InputPeerClass, requireAdmin bool) (int64, domain.ChannelView, error) {
ref, ok := premiumBoostChannelRef(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 requireAdmin && view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED")
}
return userID, view, nil
}
func premiumBoostChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) {
switch p := peer.(type) {
case *tg.InputPeerChannel:
return channelInputRef{
ID: p.ChannelID,
AccessHash: p.AccessHash,
CheckAccessHash: p.AccessHash != 0,
}, p.ChannelID > 0
case *tg.InputPeerChannelFromMessage:
return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0
case *tg.InputPeerChat:
return channelInputRef{ID: p.ChatID}, p.ChatID > 0
default:
return channelInputRef{}, false
}
}
func emptyPremiumBoostsStatus() *tg.PremiumBoostsStatus {
return &tg.PremiumBoostsStatus{
Level: 0,
CurrentLevelBoosts: 0,
Boosts: 0,
BoostURL: "",
}
}
func emptyPremiumBoostsList() *tg.PremiumBoostsList {
return &tg.PremiumBoostsList{
Count: 0,
Boosts: []tg.Boost{},
Users: []tg.UserClass{},
}
}
func emptyPremiumMyBoosts() *tg.PremiumMyBoosts {
return &tg.PremiumMyBoosts{
MyBoosts: []tg.MyBoost{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}

490
internal/rpc/presence.go Normal file
View file

@ -0,0 +1,490 @@
package rpc
import (
"context"
"sync"
"time"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const userOnlineTTL = 5 * time.Minute
const presenceDialogFanoutCandidateLimit = 512
type presenceSessionKey struct {
rawAuthKeyID [8]byte
sessionID int64
}
type presenceSessionState struct {
userID int64
status domain.UserStatus
}
type presenceTracker struct {
mu sync.RWMutex
bySession map[presenceSessionKey]presenceSessionState
byUser map[int64]map[presenceSessionKey]domain.UserStatus
}
func newPresenceTracker() *presenceTracker {
return &presenceTracker{
bySession: make(map[presenceSessionKey]presenceSessionState),
byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus),
}
}
func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) {
if p == nil || userID == 0 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if old, ok := p.bySession[key]; ok {
p.removeSessionLocked(key, old.userID)
}
p.bySession[key] = presenceSessionState{userID: userID, status: status}
sessions := p.byUser[userID]
if sessions == nil {
sessions = make(map[presenceSessionKey]domain.UserStatus)
p.byUser[userID] = sessions
}
sessions[key] = status
}
func (p *presenceTracker) clearSession(key presenceSessionKey) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if old, ok := p.bySession[key]; ok {
p.removeSessionLocked(key, old.userID)
}
}
func (p *presenceTracker) removeSessionLocked(key presenceSessionKey, userID int64) {
delete(p.bySession, key)
sessions := p.byUser[userID]
delete(sessions, key)
if len(sessions) == 0 {
delete(p.byUser, userID)
}
}
func (p *presenceTracker) statusFor(userID int64, now int) (domain.UserStatus, bool) {
if p == nil || userID == 0 {
return domain.UserStatus{}, false
}
p.mu.RLock()
defer p.mu.RUnlock()
sessions := p.byUser[userID]
if len(sessions) == 0 {
return domain.UserStatus{}, false
}
known := false
var bestOnline domain.UserStatus
var bestOffline domain.UserStatus
for _, status := range sessions {
status = normalizePresenceStatus(status, now)
switch status.Kind {
case domain.UserStatusOnline:
if bestOnline.Expires == 0 || status.Expires > bestOnline.Expires {
bestOnline = status
}
known = true
case domain.UserStatusOffline:
if bestOffline.WasOnline == 0 || status.WasOnline > bestOffline.WasOnline {
bestOffline = status
}
known = true
case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty:
if !known {
bestOffline = status
known = true
}
}
}
if bestOnline.Kind == domain.UserStatusOnline {
return bestOnline, true
}
if known {
return bestOffline, true
}
return domain.UserStatus{}, false
}
func normalizePresenceStatus(status domain.UserStatus, now int) domain.UserStatus {
if status.Kind == domain.UserStatusOnline && status.Expires <= now {
wasOnline := status.WasOnline
if wasOnline == 0 || wasOnline > status.Expires {
wasOnline = status.Expires
}
return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: wasOnline}
}
return status
}
func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool) domain.UserStatus {
now := int(r.clock.Now().Unix())
status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now}
if !offline {
status = domain.UserStatus{
Kind: domain.UserStatusOnline,
Expires: now + int(userOnlineTTL/time.Second),
WasOnline: now,
}
}
if key, ok := presenceSessionKeyFromContext(ctx); ok {
r.presence.setSessionStatus(key, userID, status)
}
r.persistLastSeen(ctx, userID, now)
return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now})
}
func (r *Router) announceSessionOnline(ctx context.Context, userID int64) {
if userID == 0 {
return
}
status := r.setPresenceFromContext(ctx, userID, false)
r.pushUserStatus(ctx, userID, status)
r.pushOnlinePeerStatusesToCurrentSession(ctx, userID)
}
func (r *Router) userPresenceStatus(userID int64) domain.UserStatus {
return r.userPresenceStatusForUser(domain.User{ID: userID})
}
func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus {
userID := u.ID
if userID == 0 {
return domain.UserStatus{Kind: domain.UserStatusRecently}
}
now := int(r.clock.Now().Unix())
if status, ok := r.presence.statusFor(userID, now); ok {
return status
}
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok && provider.IsUserOnline(userID) {
return domain.UserStatus{
Kind: domain.UserStatusOnline,
Expires: now + int(userOnlineTTL/time.Second),
WasOnline: now,
}
}
if u.LastSeenAt > 0 {
return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: u.LastSeenAt}
}
return domain.UserStatus{Kind: domain.UserStatusRecently}
}
type userLastSeenUpdater interface {
UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error
}
func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int) {
if userID == 0 || lastSeenAt <= 0 || r.deps.Users == nil {
return
}
updater, ok := r.deps.Users.(userLastSeenUpdater)
if !ok {
return
}
if err := updater.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil {
r.log.Warn("Update user last seen failed", zap.Int64("user_id", userID), zap.Int("last_seen_at", lastSeenAt), zap.Error(err))
}
}
// SessionOffline is called by mtprotoedge when an active connection disappears.
// Business-side effects stay here: mtprotoedge only reports lifecycle facts.
func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) {
if userID == 0 {
return
}
ctx := WithSessionID(WithRawAuthKeyID(context.Background(), rawAuthKeyID), sessionID)
key := presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
if !lastForUser {
r.presence.clearSession(key)
return
}
now := int(r.clock.Now().Unix())
status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now}
r.presence.setSessionStatus(key, userID, status)
r.persistLastSeen(ctx, userID, now)
r.pushUserStatus(ctx, userID, status)
}
func presenceSessionKeyFromContext(ctx context.Context) (presenceSessionKey, bool) {
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return presenceSessionKey{}, false
}
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
return presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}, true
}
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
return presenceSessionKey{rawAuthKeyID: authKeyID, sessionID: sessionID}, true
}
return presenceSessionKey{sessionID: sessionID}, true
}
func (r *Router) withUserPresence(u domain.User) domain.User {
if u.ID == 0 {
return u
}
u.Status = r.userPresenceStatusForUser(u)
return u
}
func (r *Router) withUsersPresence(users []domain.User) []domain.User {
if len(users) == 0 {
return users
}
out := append([]domain.User(nil), users...)
for i := range out {
out[i] = r.withUserPresence(out[i])
}
return out
}
func (r *Router) withContactListPresence(list domain.ContactList) domain.ContactList {
if len(list.Contacts) == 0 {
return list
}
out := list
out.Contacts = append([]domain.Contact(nil), list.Contacts...)
for i := range out.Contacts {
out.Contacts[i].User = r.withUserPresence(out.Contacts[i].User)
}
return out
}
func (r *Router) withMessageListPresence(list domain.MessageList) domain.MessageList {
list.Users = r.withUsersPresence(list.Users)
return list
}
func (r *Router) withDialogListPresence(list domain.DialogList) domain.DialogList {
list.Users = r.withUsersPresence(list.Users)
return list
}
func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.UserSearchResult {
res.MyResults = r.withUsersPresence(res.MyResults)
res.Results = r.withUsersPresence(res.Results)
return res
}
func (r *Router) tgUser(u domain.User) *tg.User {
return tgUser(r.withUserPresence(u))
}
func (r *Router) tgSelfUser(u domain.User) *tg.User {
return tgSelfUser(r.withUserPresence(u))
}
func (r *Router) tgUsers(users []domain.User) []tg.UserClass {
return tgUsers(r.withUsersPresence(users))
}
func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain.UserStatus) {
if userID == 0 {
return
}
update := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserStatus{
UserID: userID,
Status: tgUserStatus(status),
}},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
r.pushUserUpdates(ctx, userID, update)
for _, recipientID := range r.onlinePresenceRecipientIDs(ctx, userID) {
if recipientID == userID {
continue
}
r.pushUserMessage(ctx, recipientID, "push user status", update)
}
}
func (r *Router) onlinePresenceRecipientIDs(ctx context.Context, userID int64) []int64 {
if userID == 0 {
return nil
}
seen := map[int64]struct{}{}
out := make([]int64, 0)
add := func(ids []int64) {
for _, id := range ids {
if id == 0 || id == userID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
}
add(r.onlineContactIDs(ctx, userID))
add(r.onlinePrivateDialogPeerIDs(ctx, userID, seen))
return out
}
func (r *Router) onlineContactIDs(ctx context.Context, userID int64) []int64 {
if r.deps.Contacts == nil || userID == 0 {
return nil
}
ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0)
if err != nil || notModified || len(ids) == 0 {
return nil
}
candidates := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
contactID := int64(id)
if contactID == 0 || contactID == userID {
continue
}
if _, ok := seen[contactID]; ok {
continue
}
seen[contactID] = struct{}{}
candidates = append(candidates, contactID)
}
if len(candidates) == 0 {
return nil
}
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok {
return provider.OnlineUserIDsForCandidates(candidates, 0)
}
return candidates
}
func (r *Router) onlinePrivateDialogPeerIDs(ctx context.Context, userID int64, already map[int64]struct{}) []int64 {
if r.deps.Dialogs == nil || userID == 0 {
return nil
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return nil
}
candidates := provider.OnlineUserIDs(presenceDialogFanoutCandidateLimit)
if len(candidates) == 0 {
return nil
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
out := make([]int64, 0, len(candidates))
for _, candidateID := range candidates {
if candidateID == 0 || candidateID == userID {
continue
}
if _, ok := already[candidateID]; ok {
continue
}
list, err := r.deps.Dialogs.GetPeerDialogs(ctx, candidateID, []domain.Peer{peer})
if err != nil || !dialogListHasPeer(list, peer) {
continue
}
out = append(out, candidateID)
}
return out
}
func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, userID int64) {
peerIDs := r.onlineRelevantPeerIDs(ctx, userID)
if len(peerIDs) == 0 {
return
}
updates := make([]tg.UpdateClass, 0, len(peerIDs))
for _, peerID := range peerIDs {
status := r.userPresenceStatus(peerID)
if status.Kind != domain.UserStatusOnline {
continue
}
updates = append(updates, &tg.UpdateUserStatus{
UserID: peerID,
Status: tgUserStatus(status),
})
}
if len(updates) == 0 {
return
}
r.pushCurrentSessionMessage(ctx, "push online peer statuses", &tg.Updates{
Updates: updates,
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
}
func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int64 {
if userID == 0 {
return nil
}
provider, ok := r.deps.Sessions.(OnlineUserProvider)
if !ok {
return nil
}
seen := map[int64]struct{}{}
candidates := make([]int64, 0)
add := func(id int64) {
if id == 0 || id == userID {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
candidates = append(candidates, id)
}
if r.deps.Contacts != nil {
ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0)
if err == nil && !notModified {
for _, id := range ids {
add(int64(id))
}
}
}
if r.deps.Dialogs != nil {
list, err := r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{Limit: presenceDialogFanoutCandidateLimit})
if err == nil {
for _, dialog := range list.Dialogs {
if dialog.Peer.Type == domain.PeerTypeUser {
add(dialog.Peer.ID)
}
}
}
}
if len(candidates) == 0 {
return nil
}
return provider.OnlineUserIDsForCandidates(candidates, 0)
}
func dialogListHasPeer(list domain.DialogList, peer domain.Peer) bool {
for _, dialog := range list.Dialogs {
if dialog.Peer != peer {
continue
}
if dialog.TopMessage != 0 ||
dialog.TopMessageDate != 0 ||
dialog.ReadInboxMaxID != 0 ||
dialog.ReadOutboxMaxID != 0 ||
dialog.UnreadCount != 0 ||
dialog.UnreadMentions != 0 ||
dialog.UnreadReactions != 0 ||
dialog.Pinned ||
dialog.UnreadMark ||
dialog.Draft != nil {
return true
}
for _, msg := range list.Messages {
if msg.Peer == peer {
return true
}
}
}
return false
}

73
internal/rpc/push.go Normal file
View file

@ -0,0 +1,73 @@
package rpc
import (
"context"
"github.com/gotd/td/bin"
"github.com/gotd/td/proto"
"go.uber.org/zap"
)
func (r *Router) pushUserMessage(ctx context.Context, userID int64, logMessage string, msg bin.Encoder) int {
if r.deps.Sessions == nil || userID == 0 || msg == nil {
return 0
}
sessionID, _ := SessionIDFrom(ctx)
if timeout := r.cfg.OutboundPushTimeout; timeout > 0 {
authKeyID, _ := AuthKeyIDFrom(ctx)
if scoped, ok := r.deps.Sessions.(ScopedBestEffortSessionBinder); ok {
if sent, err := scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg, timeout); err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err))
return sent
} else {
return sent
}
}
if bestEffort, ok := r.deps.Sessions.(BestEffortSessionBinder); ok {
if sent, err := bestEffort.PushToUserExceptSessionBestEffort(ctx, userID, sessionID, proto.MessageFromServer, msg, timeout); err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err))
return sent
} else {
return sent
}
}
}
if scoped, ok := r.scopedSessions(); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
if sent, err := scoped.PushToUserExceptAuthKeySession(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg); err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err))
return sent
} else {
return sent
}
}
if sent, err := r.deps.Sessions.PushToUserExceptSession(ctx, userID, sessionID, proto.MessageFromServer, msg); err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err))
return sent
} else {
return sent
}
}
func (r *Router) pushCurrentSessionMessage(ctx context.Context, logMessage string, msg bin.Encoder) {
if r.deps.Sessions == nil || msg == nil {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
if scoped, ok := r.scopedSessions(); ok {
rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx)
if !ok {
return
}
if err := scoped.PushToSessionForAuthKey(ctx, rawAuthKeyID, sessionID, proto.MessageFromServer, msg); err != nil {
r.log.Debug(logMessage, zap.Int64("session_id", sessionID), zap.Error(err))
}
return
}
if err := r.deps.Sessions.PushToSession(ctx, sessionID, proto.MessageFromServer, msg); err != nil {
r.log.Debug(logMessage, zap.Int64("session_id", sessionID), zap.Error(err))
}
}

416
internal/rpc/router.go Normal file
View file

@ -0,0 +1,416 @@
package rpc
import (
"context"
"encoding/hex"
"fmt"
"sync"
"time"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
)
// maxWrapperDepth 限制 invokeWithLayer/initConnection 等 wrapper 的嵌套深度,防御恶意构造。
const maxWrapperDepth = 4
var (
tlTypeNamesOnce sync.Once
tlTypeNames map[uint32]string
)
// Config 是 Router 所需的服务端信息。
type Config struct {
DC int
IP string // 对外公布的 DC IP写入 DCOptions
Port int // 对外公布的 DC 端口
OutboundPushTimeout time.Duration
}
// Router 把解密后的 RPC 请求按 TypeID 路由到 typed handlertg.ServerDispatcher
//
// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler
// 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳:
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates并兜底未注册 RPC。
type Router struct {
cfg Config
log *zap.Logger
clock clock.Clock
deps Deps
dispatcher *tg.ServerDispatcher
clientInfoMu sync.RWMutex
clientInfo map[clientInfoSessionKey]ClientInfo
authUserMu sync.RWMutex
authUsers map[[8]byte]authUserCacheEntry
authUserSF singleflight.Group
presence *presenceTracker
}
type clientInfoSessionKey struct {
rawAuthKeyID [8]byte
sessionID int64
}
type authUserCacheEntry struct {
userID int64
found bool
}
// New 创建 Router由各业务域自行注册其 RPC handlerregisterHelp/Auth/Users/Updates
func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, presence: newPresenceTracker()}
d := tg.NewServerDispatcher(r.fallback)
r.registerHelp(d)
r.registerAuth(d)
r.registerUsers(d)
r.registerUpdates(d)
r.registerAccount(d)
r.registerMessages(d)
r.registerChannels(d)
r.registerUpload(d)
r.registerPhotos(d)
r.registerFolders(d)
r.registerContacts(d)
r.registerLangpack(d)
r.registerStories(d)
r.registerPayments(d)
r.registerStats(d)
r.registerPremium(d)
r.registerAiCompose(d)
r.dispatcher = d
return r
}
// Dispatch 路由一条 RPC 请求:先剥离 invokeWithLayer / initConnection /
// invokeWithoutUpdates 等 wrapper注入 layer / 客户端信息到 ctx
// 再按 TypeID 路由到 typed handler。满足 mtprotoedge.RPCHandler。
func (r *Router) Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) {
ctx = WithRawAuthKeyID(ctx, authKeyID)
effectiveAuthKeyID, err := r.effectiveAuthKeyID(ctx, authKeyID, sessionID)
if err != nil {
return nil, internalErr()
}
ctx = WithAuthKeyID(ctx, effectiveAuthKeyID)
ctx = WithSessionID(ctx, sessionID)
userID, hasUserID, err := r.effectiveUserID(ctx, authKeyID, effectiveAuthKeyID, sessionID)
if err != nil {
return nil, internalErr()
}
if hasUserID {
ctx = WithUserID(ctx, userID)
}
if info, ok := r.clientInfoForSession(ctx); ok {
ctx = WithClientInfo(ctx, info)
}
return r.dispatch(ctx, b, 0)
}
func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64) ([8]byte, error) {
if r.deps.Sessions != nil {
if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok {
if id, ok := scoped.AuthKeyIDForSession(rawAuthKeyID, sessionID); ok {
return id, nil
}
} else if id, ok := r.deps.Sessions.AuthKeyID(sessionID); ok {
return id, nil
}
}
effective := rawAuthKeyID
if r.deps.Auth != nil {
resolved, ok, err := r.deps.Auth.ResolveAuthKey(ctx, rawAuthKeyID)
if err != nil {
return [8]byte{}, err
}
if ok {
effective = resolved
}
}
if r.deps.Sessions != nil {
if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok {
scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, effective)
} else {
r.deps.Sessions.BindAuthKey(sessionID, effective)
}
}
return effective, nil
}
func (r *Router) effectiveUserID(ctx context.Context, rawAuthKeyID, authKeyID [8]byte, sessionID int64) (int64, bool, error) {
if userID, ok := UserIDFrom(ctx); ok {
if scoped, ok := r.scopedSessions(); ok {
scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID)
} else if r.deps.Sessions != nil {
r.deps.Sessions.BindUser(sessionID, userID)
}
return userID, true, nil
}
if r.deps.Sessions != nil {
if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok {
if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved {
return userID, userID != 0, nil
}
} else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved {
return userID, userID != 0, nil
}
}
if r.deps.Auth == nil {
return 0, false, nil
}
userID, found, err := r.lookupAuthUser(ctx, authKeyID)
if err != nil {
return 0, false, err
}
if r.deps.Sessions != nil {
if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok {
if cachedUserID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved {
return cachedUserID, cachedUserID != 0, nil
}
} else if cachedUserID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved {
return cachedUserID, cachedUserID != 0, nil
}
if found {
if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok {
scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID)
} else {
r.deps.Sessions.BindUser(sessionID, userID)
}
r.announceSessionOnline(ctx, userID)
} else {
if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok {
scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, 0)
} else {
r.deps.Sessions.BindUser(sessionID, 0)
}
}
}
return userID, found, nil
}
func (r *Router) lookupAuthUser(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
if userID, found, ok := r.cachedAuthUser(authKeyID); ok {
return userID, found, nil
}
key := string(authKeyID[:])
v, err, _ := r.authUserSF.Do(key, func() (any, error) {
if userID, found, ok := r.cachedAuthUser(authKeyID); ok {
return authUserCacheEntry{userID: userID, found: found}, nil
}
userID, found, err := r.deps.Auth.UserID(ctx, authKeyID)
if err != nil {
return authUserCacheEntry{}, err
}
r.setAuthUserCache(authKeyID, userID, found)
return authUserCacheEntry{userID: userID, found: found}, nil
})
if err != nil {
return 0, false, err
}
entry := v.(authUserCacheEntry)
return entry.userID, entry.found, nil
}
func (r *Router) cachedAuthUser(authKeyID [8]byte) (int64, bool, bool) {
r.authUserMu.RLock()
defer r.authUserMu.RUnlock()
entry, ok := r.authUsers[authKeyID]
if !ok {
return 0, false, false
}
return entry.userID, entry.found, true
}
func (r *Router) setAuthUserCache(authKeyID [8]byte, userID int64, found bool) {
r.authUserMu.Lock()
defer r.authUserMu.Unlock()
if r.authUsers == nil {
r.authUsers = make(map[[8]byte]authUserCacheEntry)
}
r.authUsers[authKeyID] = authUserCacheEntry{userID: userID, found: found}
}
func (r *Router) invalidateAuthUserCache(authKeyID [8]byte) {
r.authUserMu.Lock()
delete(r.authUsers, authKeyID)
r.authUserMu.Unlock()
r.authUserSF.Forget(string(authKeyID[:]))
}
func (r *Router) scopedSessions() (ScopedSessionBinder, bool) {
if r.deps.Sessions == nil {
return nil, false
}
scoped, ok := r.deps.Sessions.(ScopedSessionBinder)
return scoped, ok
}
func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.Encoder, error) {
if depth > maxWrapperDepth {
return nil, wrapperTooDeepErr()
}
id, err := b.PeekID()
if err != nil {
return nil, err
}
switch id {
case tg.InvokeWithLayerRequestTypeID:
if err := b.ConsumeID(id); err != nil {
return nil, err
}
layer, err := b.Int()
if err != nil {
return nil, fmt.Errorf("decode invokeWithLayer layer: %w", err)
}
// query 紧跟 layerbuffer 剩余即内层请求。
return r.dispatch(WithLayer(ctx, layer), b, depth+1)
case tg.InvokeWithoutUpdatesRequestTypeID:
if err := b.ConsumeID(id); err != nil {
return nil, err
}
return r.dispatch(ctx, b, depth+1)
case tg.InitConnectionRequestTypeID:
req := &tg.InitConnectionRequest{Query: &rawObject{}}
if err := req.Decode(b); err != nil {
return nil, fmt.Errorf("decode initConnection: %w", err)
}
info := ClientInfo{
APIID: req.APIID,
DeviceModel: req.DeviceModel,
SystemVersion: req.SystemVersion,
AppVersion: req.AppVersion,
SystemLangCode: req.SystemLangCode,
LangPack: req.LangPack,
LangCode: req.LangCode,
}
ctx = WithClientInfo(ctx, info)
r.rememberClientInfo(ctx, info)
r.log.Debug("initConnection",
zap.Int("api_id", req.APIID),
zap.String("device", req.DeviceModel),
zap.String("app", req.AppVersion),
zap.Int("layer", LayerFrom(ctx)),
)
inner, ok := req.Query.(*rawObject)
if !ok {
return nil, fmt.Errorf("initConnection query: unexpected type %T", req.Query)
}
return r.dispatch(ctx, &bin.Buffer{Buf: inner.data}, depth+1)
default:
start := time.Now()
enc, err := r.dispatcher.Handle(ctx, b)
dur := time.Since(start)
fields := append([]zap.Field{
zap.String("method", tlTypeName(id)),
zap.String("type_id", fmt.Sprintf("%#x", id)),
zap.Duration("dur", dur),
}, r.contextLogFields(ctx)...)
if err != nil || dur > 100*time.Millisecond {
if err != nil {
fields = append(fields, zap.Error(err))
}
r.log.Info("RPC inner handled", fields...)
} else {
r.log.Debug("RPC inner handled", fields...)
}
return enc, err
}
}
func tlTypeName(id uint32) string {
tlTypeNamesOnce.Do(func() {
names := tg.NamesMap()
tlTypeNames = make(map[uint32]string, len(names))
for name, typeID := range names {
tlTypeNames[typeID] = name
}
})
if name, ok := tlTypeNames[id]; ok {
return name
}
return fmt.Sprintf("%#x", id)
}
func (r *Router) rememberClientInfo(ctx context.Context, info ClientInfo) {
rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx)
if !ok {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
if r.clientInfo == nil {
r.clientInfo = make(map[clientInfoSessionKey]ClientInfo)
}
r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] = info
}
func (r *Router) clientInfoForSession(ctx context.Context) (ClientInfo, bool) {
rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx)
if !ok {
return ClientInfo{}, false
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return ClientInfo{}, false
}
r.clientInfoMu.RLock()
defer r.clientInfoMu.RUnlock()
info, ok := r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}]
return info, ok
}
// fallback 处理未注册的 RPC记录到 compatibility trace落兼容矩阵
// 返回 NOT_IMPLEMENTED rpc_error 让客户端继续运行而非断连。
func (r *Router) fallback(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) {
id, _ := b.PeekID()
fields := append([]zap.Field{zap.String("type_id", fmt.Sprintf("%#x", id))}, r.contextLogFields(ctx)...)
r.log.Warn("Unhandled RPC (compatibility trace)", fields...)
return nil, notImplementedErr()
}
func (r *Router) contextLogFields(ctx context.Context) []zap.Field {
fields := []zap.Field{zap.Int("layer", LayerFrom(ctx))}
if sessionID, ok := SessionIDFrom(ctx); ok {
fields = append(fields, zap.Int64("session_id", sessionID))
}
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
fields = append(fields, zap.String("raw_auth_key_id", hex.EncodeToString(rawAuthKeyID[:])))
}
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
fields = append(fields, zap.String("auth_key_id", hex.EncodeToString(authKeyID[:])))
}
if userID, ok := UserIDFrom(ctx); ok {
fields = append(fields, zap.Int64("user_id", userID))
}
return fields
}
// rawObject 在解码 wrapper 时按原样捕获内层 query 的 TL 字节,供递归分发。
// 它实现 bin.ObjectEncode/Decode但只搬运字节、不解释内容。
type rawObject struct {
data []byte
}
func (o *rawObject) Decode(b *bin.Buffer) error {
o.data = append(o.data[:0], b.Buf...)
b.Skip(len(b.Buf))
return nil
}
func (o *rawObject) Encode(b *bin.Buffer) error {
b.Put(o.data)
return nil
}

10137
internal/rpc/router_test.go Normal file

File diff suppressed because it is too large Load diff

539
internal/rpc/send_media.go Normal file
View file

@ -0,0 +1,539 @@
package rpc
import (
"context"
"errors"
"strconv"
"unicode/utf8"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// 本文件实现 messages.uploadMedia / sendMedia / sendMultiMedia 的 photo/document/sticker 主路径,
// 并抽取 sendOutgoing 作为「已校验的一条出站消息(文本或媒体)落地」的共享实现,私聊与频道共用。
// outgoingSend 是 sendOutgoing 的入参:一条已校验的出站消息。
type outgoingSend struct {
randomID int64
message string
entities []tg.MessageEntityClass
media *domain.MessageMedia
silent bool
noforwards bool
replyToInput tg.InputReplyToClass
sendAsInput tg.InputPeerClass
clearDraft bool
}
// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。
// media 为空即纯文本。校验(长度/random_id/限流)由调用方完成。
func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Peer, p outgoingSend) (tg.UpdatesClass, bool, error) {
sendAs, err := r.resolveSendAsPeer(ctx, userID, peer, p.sendAsInput)
if err != nil {
return nil, false, err
}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return nil, false, peerIDInvalidErr()
}
replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput)
if err != nil {
return nil, false, err
}
mentionUserIDs, err := r.mentionedUserIDsFromMessage(ctx, userID, p.message, p.entities)
if err != nil {
return nil, false, err
}
res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{
UserID: userID,
ChannelID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntities(p.entities),
Media: p.media,
MentionUserIDs: mentionUserIDs,
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
SendAs: sendAs,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, false, channelInvalidErr(err)
}
updates := r.channelMessageUpdates(ctx, userID, res, p.randomID)
if !res.Duplicate {
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelMessageUpdates(ctx, viewerUserID, res, 0)
})
r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion)
}
if p.clearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return updates, res.Duplicate, nil
}
if peer.Type != domain.PeerTypeUser {
return nil, false, peerIDInvalidErr()
}
if r.deps.Messages == nil {
return nil, false, peerIDInvalidErr()
}
if r.deps.Users != nil && peer.ID != userID {
if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil {
return nil, false, internalErr()
} else if !found {
return nil, false, peerIDInvalidErr()
}
}
replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput)
if err != nil {
return nil, false, err
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntities(p.entities),
Media: p.media,
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
})
if err != nil {
return nil, false, messageSendErr(err)
}
users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
if p.clearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, p.randomID, true, users, chats), res.Duplicate, nil
}
// onMessagesUploadMedia 解析 InputMedia上传或引用返回可复用的 tg.MessageMedia。
func (r *Router) onMessagesUploadMedia(ctx context.Context, req *tg.MessagesUploadMediaRequest) (tg.MessageMediaClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, mediaInvalidErr()
}
if len(req.BusinessConnectionID) > maxBusinessConnIDLength {
return nil, limitInvalidErr()
}
if _, ok := req.Peer.(*tg.InputPeerEmpty); !ok {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
}
if _, ok := req.Media.(*tg.InputMediaEmpty); ok {
return &tg.MessageMediaEmpty{}, nil
}
media, err := r.resolveInputMedia(ctx, userID, req.Media)
if err != nil {
return nil, err
}
if media == nil {
return nil, mediaInvalidErr()
}
return tgMessageMedia(media), nil
}
// onMessagesSendMedia 发送一条带媒体的消息photo/document/sticker私聊与频道均支持。
func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMediaRequest) (tg.UpdatesClass, error) {
if req.RandomID == 0 {
return nil, randomIDEmptyErr()
}
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
return nil, mediaCaptionTooLongErr()
}
if len(req.Entities) > maxMessageEntityCount {
return nil, limitInvalidErr()
}
if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 {
return nil, scheduleDateInvalidErr()
}
if req.Media == nil {
return nil, mediaInvalidErr()
}
// InputMediaEmpty / WebPage退化为纯文本发送复用 sendMessage 校验与流程)。
switch req.Media.(type) {
case *tg.InputMediaEmpty, *tg.InputMediaWebPage:
return r.onMessagesSendMessage(ctx, sendMessageRequestFromSendMedia(req))
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, peerIDInvalidErr()
}
if r.deps.Limiter != nil {
allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:send:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow)
if err != nil {
return nil, internalErr()
}
if !allowed {
r.metrics().MessageRateLimited(retryAfter)
return nil, floodWaitErr(retryAfter)
}
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
media, err := r.resolveInputMedia(ctx, userID, req.Media)
if err != nil {
return nil, err
}
if media == nil {
return nil, mediaInvalidErr()
}
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: req.Message,
entities: req.Entities,
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
})
if err != nil {
return nil, err
}
return updates, nil
}
// onMessagesSendMultiMedia 发送相册(多条媒体)。本阶段不绑定 grouped_id各条作为独立消息呈现
func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesSendMultiMediaRequest) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, peerIDInvalidErr()
}
if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems {
return nil, limitInvalidErr()
}
if req.ScheduleDate != 0 {
return nil, scheduleDateInvalidErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
for _, item := range req.MultiMedia {
if item.RandomID == 0 {
return nil, randomIDEmptyErr()
}
if utf8.RuneCountInString(item.Message) > maxSendMessageTextLength {
return nil, mediaCaptionTooLongErr()
}
if len(item.Entities) > maxMessageEntityCount {
return nil, limitInvalidErr()
}
if item.Media == nil {
return nil, mediaInvalidErr()
}
}
combined := make([]tg.UpdateClass, 0, len(req.MultiMedia)*2)
usersByID := map[int64]tg.UserClass{}
chatsByID := map[int64]tg.ChatClass{}
date := 0
for _, item := range req.MultiMedia {
media, err := r.resolveInputMedia(ctx, userID, item.Media)
if err != nil {
return nil, err
}
if media == nil {
return nil, mediaInvalidErr()
}
result, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: item.RandomID,
message: item.Message,
entities: item.Entities,
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
})
if err != nil {
return nil, err
}
if upd, ok := result.(*tg.Updates); ok {
combined = append(combined, upd.Updates...)
for _, u := range upd.Users {
if id := userClassID(u); id != 0 {
usersByID[id] = u
}
}
for _, c := range upd.Chats {
if id := chatClassID(c); id != 0 {
chatsByID[id] = c
}
}
if upd.Date != 0 {
date = upd.Date
}
}
}
return &tg.Updates{
Updates: combined,
Users: mapValuesUsers(usersByID),
Chats: mapValuesChats(chatsByID),
Date: date,
}, nil
}
// resolveInputMedia 把 tg.InputMedia 解析为 domain.MessageMedia上传则落库引用则加载
// 返回 nil 表示 InputMediaEmpty调用方退化为纯文本
func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) {
if r.deps.Files == nil {
return nil, mediaInvalidErr()
}
switch in := input.(type) {
case *tg.InputMediaEmpty:
return nil, nil
case *tg.InputMediaUploadedPhoto:
if in.File == nil {
return nil, mediaInvalidErr()
}
ref, ok := uploadedFileRef(userID, in.File)
if !ok {
return nil, fileReferenceInvalidErr()
}
photo, err := r.deps.Files.CreatePhotoFromUpload(ctx, ref)
if err != nil {
return nil, mediaUploadErr(err)
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil
case *tg.InputMediaUploadedDocument:
if in.File == nil {
return nil, mediaInvalidErr()
}
ref, ok := uploadedFileRef(userID, in.File)
if !ok {
return nil, fileReferenceInvalidErr()
}
spec := domain.DocumentSpec{
MimeType: in.MimeType,
Attributes: domainDocumentAttributes(in.Attributes),
ForceFile: in.ForceFile,
}
if thumb, ok := in.GetThumb(); ok {
if tref, ok := uploadedFileRef(userID, thumb); ok {
spec.Thumb = &tref
}
}
doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec)
if err != nil {
return nil, mediaUploadErr(err)
}
return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil
case *tg.InputMediaPhoto:
photoID, ok := inputPhotoID(in.ID)
if !ok {
return nil, photoInvalidErr()
}
photo, found, err := r.deps.Files.GetPhoto(ctx, photoID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil
case *tg.InputMediaDocument:
docIDs, ok := inputDocumentCandidateIDs(in.ID)
if !ok {
return nil, mediaInvalidErr()
}
var doc domain.Document
found := false
for _, docID := range docIDs {
var err error
doc, found, err = r.deps.Files.GetDocument(ctx, docID)
if err != nil {
return nil, internalErr()
}
if found {
break
}
}
if !found {
return nil, mediaInvalidErr()
}
return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil
default:
// geo / contact / poll / venue / dice / story / 等本阶段不支持。
return nil, mediaInvalidErr()
}
}
// messageMediaFromDocument 由 Document 构造 MessageMedia并从属性推导 Video/Round/Voice 标志。
func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia {
media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl}
for _, attr := range doc.Attributes {
switch attr.Kind {
case domain.DocAttrVideo:
media.Video = true
if attr.RoundMessage {
media.Round = true
}
case domain.DocAttrAudio:
if attr.Voice {
media.Voice = true
}
}
}
return media
}
func inputPhotoID(input tg.InputPhotoClass) (int64, bool) {
if p, ok := input.(*tg.InputPhoto); ok && p.ID != 0 {
return p.ID, true
}
return 0, false
}
func inputDocumentID(input tg.InputDocumentClass) (int64, bool) {
if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 {
return d.ID, true
}
return 0, false
}
func inputDocumentCandidateIDs(input tg.InputDocumentClass) ([]int64, bool) {
if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 {
return []int64{d.ID}, true
}
return nil, false
}
// domainDocumentAttributes 把 tg.DocumentAttribute 反向转为 domainInputMediaUploadedDocument 用)。
func domainDocumentAttributes(attrs []tg.DocumentAttributeClass) []domain.DocumentAttribute {
out := make([]domain.DocumentAttribute, 0, len(attrs))
for _, a := range attrs {
switch v := a.(type) {
case *tg.DocumentAttributeImageSize:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: v.W, H: v.H})
case *tg.DocumentAttributeAnimated:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
case *tg.DocumentAttributeSticker:
attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: v.Alt, Mask: v.Mask}
if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok {
attr.StickerSetID = id
attr.StickerSetAccessHash = hash
}
out = append(out, attr)
case *tg.DocumentAttributeVideo:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming})
case *tg.DocumentAttributeAudio:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: v.Duration, Voice: v.Voice, Title: v.Title, Performer: v.Performer, Waveform: v.Waveform})
case *tg.DocumentAttributeFilename:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: v.FileName})
case *tg.DocumentAttributeCustomEmoji:
attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: v.Alt, Free: v.Free, TextColor: v.TextColor}
if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok {
attr.StickerSetID = id
attr.StickerSetAccessHash = hash
}
out = append(out, attr)
}
}
return out
}
func inputStickerSetIDs(input tg.InputStickerSetClass) (int64, int64, bool) {
if s, ok := input.(*tg.InputStickerSetID); ok {
return s.ID, s.AccessHash, true
}
return 0, 0, false
}
// sendMessageRequestFromSendMedia 把 sendMedia空媒体的字段映射到 sendMessage 请求。
func sendMessageRequestFromSendMedia(req *tg.MessagesSendMediaRequest) *tg.MessagesSendMessageRequest {
return &tg.MessagesSendMessageRequest{
Silent: req.Silent,
Background: req.Background,
ClearDraft: req.ClearDraft,
Noforwards: req.Noforwards,
UpdateStickersetsOrder: req.UpdateStickersetsOrder,
InvertMedia: req.InvertMedia,
AllowPaidFloodskip: req.AllowPaidFloodskip,
Peer: req.Peer,
ReplyTo: req.ReplyTo,
Message: req.Message,
RandomID: req.RandomID,
ReplyMarkup: req.ReplyMarkup,
Entities: req.Entities,
ScheduleDate: req.ScheduleDate,
ScheduleRepeatPeriod: req.ScheduleRepeatPeriod,
SendAs: req.SendAs,
QuickReplyShortcut: req.QuickReplyShortcut,
Effect: req.Effect,
AllowPaidStars: req.AllowPaidStars,
SuggestedPost: req.SuggestedPost,
}
}
func mediaUploadErr(err error) error {
switch {
case errors.Is(err, domain.ErrFilePartsInvalid):
return filePartsInvalidErr()
case errors.Is(err, domain.ErrPhotoInvalid):
return photoInvalidErr()
case errors.Is(err, domain.ErrDocumentInvalid):
return mediaInvalidErr()
default:
return internalErr()
}
}
func userClassID(u tg.UserClass) int64 {
if v, ok := u.(*tg.User); ok {
return v.ID
}
return 0
}
func chatClassID(c tg.ChatClass) int64 {
switch v := c.(type) {
case *tg.Channel:
return v.ID
case *tg.Chat:
return v.ID
}
return 0
}
func mapValuesUsers(m map[int64]tg.UserClass) []tg.UserClass {
out := make([]tg.UserClass, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
func mapValuesChats(m map[int64]tg.ChatClass) []tg.ChatClass {
out := make([]tg.ChatClass, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}

View file

@ -0,0 +1,238 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appmessages "telesrv/internal/app/messages"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeFiles 是 FilesService 的最小测试替身:贴纸文档可解析,上传图片返回固定 Photo。
type fakeFiles struct {
docs map[int64]domain.Document
photos map[int64]domain.Photo
reactions []domain.AvailableReaction
sets map[domain.StickerSetKind][]domain.StickerSet
}
func (f *fakeFiles) SaveFilePart(context.Context, int64, int64, int, []byte) (bool, error) {
return true, nil
}
func (f *fakeFiles) SaveBigFilePart(context.Context, int64, int64, int, int, []byte) (bool, error) {
return true, nil
}
func (f *fakeFiles) GetFile(context.Context, domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
return domain.FileChunk{}, false, nil
}
func (f *fakeFiles) ListAvailableReactions(context.Context) ([]domain.AvailableReaction, error) {
return append([]domain.AvailableReaction(nil), f.reactions...), nil
}
func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
out := make([]domain.Document, 0, len(ids))
for _, id := range ids {
if d, ok := f.docs[id]; ok {
out = append(out, d)
}
}
return out, nil
}
func (f *fakeFiles) ResolveStickerSet(context.Context, domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
return domain.StickerSet{}, nil, false, nil
}
func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
sets := f.sets[kind]
return append([]domain.StickerSet(nil), sets...), nil
}
func (f *fakeFiles) CreatePhotoFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) {
return domain.Photo{ID: 777, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}}, nil
}
func (f *fakeFiles) CreateAvatarFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) {
return domain.Photo{ID: 778, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160}, {Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640}}}, nil
}
func (f *fakeFiles) CreateDocumentFromUpload(_ context.Context, _ domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
return domain.Document{ID: 888, AccessHash: 8, DCID: 2, MimeType: spec.MimeType, Attributes: spec.Attributes}, nil
}
func (f *fakeFiles) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) {
p, ok := f.photos[id]
return p, ok, nil
}
func (f *fakeFiles) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) {
d, ok := f.docs[id]
return d, ok, nil
}
func (f *fakeFiles) UploadProfilePhoto(context.Context, domain.PeerType, int64, domain.UploadedFileRef, int) (domain.Photo, error) {
return domain.Photo{}, nil
}
func (f *fakeFiles) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) {
return domain.Photo{}, false, nil
}
func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) {
return domain.Photo{}, false, nil
}
func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) {
return nil, 0, nil
}
func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) {
return 0, nil
}
func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550009002", FirstName: "Friend"})
dialogStore := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogStore)
files := &fakeFiles{
docs: map[int64]domain.Document{
555: {
ID: 555,
AccessHash: 5,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 99, StickerSetAccessHash: 7}},
},
},
photos: map[int64]domain.Photo{},
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Messages: appmessages.NewService(messageStore, dialogStore),
Files: files,
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
return r, owner, friend
}
func newMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
t.Helper()
upd, ok := updates.(*tg.Updates)
if !ok {
t.Fatalf("expected *tg.Updates, got %T", updates)
}
for _, u := range upd.Updates {
if nm, ok := u.(*tg.UpdateNewMessage); ok {
msg, ok := nm.Message.(*tg.Message)
if !ok {
t.Fatalf("expected *tg.Message, got %T", nm.Message)
}
return msg
}
}
t.Fatal("no UpdateNewMessage found")
return nil
}
func TestSendMediaPrivateSticker(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}},
RandomID: 1001,
})
if err != nil {
t.Fatalf("sendMedia sticker: %v", err)
}
msg := newMessageFromUpdates(t, updates)
media, ok := msg.Media.(*tg.MessageMediaDocument)
if !ok {
t.Fatalf("expected MessageMediaDocument, got %T", msg.Media)
}
doc, ok := media.Document.(*tg.Document)
if !ok {
t.Fatalf("expected tg.Document, got %T", media.Document)
}
if want := int64(555); doc.ID != want {
t.Errorf("document id = %d, want %d", doc.ID, want)
}
if doc.DCID != 2 {
t.Errorf("document dc_id = %d, want 2", doc.DCID)
}
hasSticker := false
for _, a := range doc.Attributes {
if _, ok := a.(*tg.DocumentAttributeSticker); ok {
hasSticker = true
}
}
if !hasSticker {
t.Error("document missing sticker attribute")
}
}
func TestSendMediaPrivateUploadedPhoto(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 42, Parts: 1, Name: "p.jpg"}},
Message: "caption",
RandomID: 1002,
})
if err != nil {
t.Fatalf("sendMedia photo: %v", err)
}
msg := newMessageFromUpdates(t, updates)
if msg.Message != "caption" {
t.Errorf("caption = %q, want %q", msg.Message, "caption")
}
media, ok := msg.Media.(*tg.MessageMediaPhoto)
if !ok {
t.Fatalf("expected MessageMediaPhoto, got %T", msg.Media)
}
photo, ok := media.Photo.(*tg.Photo)
if !ok {
t.Fatalf("expected tg.Photo, got %T", media.Photo)
}
if photo.ID != 777 {
t.Errorf("photo id = %d, want 777", photo.ID)
}
}
func TestUploadMediaReturnsReusableMedia(t *testing.T) {
ctx := context.Background()
r, owner, _ := newMediaTestRouter(t)
media, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{
Peer: &tg.InputPeerEmpty{},
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}},
})
if err != nil {
t.Fatalf("uploadMedia: %v", err)
}
if _, ok := media.(*tg.MessageMediaDocument); !ok {
t.Fatalf("expected MessageMediaDocument, got %T", media)
}
}
func TestStickerSetDoesNotExposeUnserviceableDownloadThumb(t *testing.T) {
set := tgStickerSet(domain.StickerSet{
ID: 99,
AccessHash: 7,
Title: "Set",
ShortName: "set",
ThumbDCID: 2,
ThumbVersion: 123,
Thumbs: []domain.PhotoSize{
{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}},
{Kind: domain.PhotoSizeKindDefault, Type: "a", W: 100, H: 100, Size: 4096},
},
})
thumbs, ok := set.GetThumbs()
if !ok || len(thumbs) != 1 {
t.Fatalf("thumbs = %#v, want only non-downloadable path thumb", thumbs)
}
if _, ok := thumbs[0].(*tg.PhotoPathSize); !ok {
t.Fatalf("thumb[0] = %T, want PhotoPathSize", thumbs[0])
}
}

211
internal/rpc/stats.go Normal file
View file

@ -0,0 +1,211 @@
package rpc
import (
"context"
"fmt"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
const (
maxStatsPublicForwardsLimit = 100
maxStatsOffsetLength = 128
maxStatsGraphTokenLength = 128
)
func (r *Router) registerStats(d *tg.ServerDispatcher) {
d.OnStatsGetBroadcastStats(r.onStatsGetBroadcastStats)
d.OnStatsGetMegagroupStats(r.onStatsGetMegagroupStats)
d.OnStatsGetMessageStats(r.onStatsGetMessageStats)
d.OnStatsGetMessagePublicForwards(r.onStatsGetMessagePublicForwards)
d.OnStatsLoadAsyncGraph(r.onStatsLoadAsyncGraph)
d.OnStatsGetStoryStats(r.onStatsGetStoryStats)
d.OnStatsGetStoryPublicForwards(r.onStatsGetStoryPublicForwards)
d.OnStatsGetPollStats(r.onStatsGetPollStats)
}
func (r *Router) onStatsGetBroadcastStats(ctx context.Context, req *tg.StatsGetBroadcastStatsRequest) (*tg.StatsBroadcastStats, error) {
view, err := r.statsChannelView(ctx, req.Channel)
if err != nil {
return nil, err
}
if !view.Channel.Broadcast {
return nil, tgerr400("BROADCAST_REQUIRED")
}
return r.emptyBroadcastStats(), nil
}
func (r *Router) onStatsGetMegagroupStats(ctx context.Context, req *tg.StatsGetMegagroupStatsRequest) (*tg.StatsMegagroupStats, error) {
view, err := r.statsChannelView(ctx, req.Channel)
if err != nil {
return nil, err
}
if !view.Channel.Megagroup {
return nil, tgerr400("MEGAGROUP_REQUIRED")
}
return r.emptyMegagroupStats(), nil
}
func (r *Router) onStatsGetMessageStats(ctx context.Context, req *tg.StatsGetMessageStatsRequest) (*tg.StatsMessageStats, error) {
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if _, err := r.statsChannelView(ctx, req.Channel); err != nil {
return nil, err
}
return &tg.StatsMessageStats{
ViewsGraph: r.emptyStatsGraph("Views"),
ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"),
}, nil
}
func (r *Router) onStatsGetMessagePublicForwards(ctx context.Context, req *tg.StatsGetMessagePublicForwardsRequest) (*tg.StatsPublicForwards, error) {
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength {
return nil, limitInvalidErr()
}
if _, err := r.statsChannelView(ctx, req.Channel); err != nil {
return nil, err
}
return emptyStatsPublicForwards(), nil
}
func (r *Router) onStatsLoadAsyncGraph(_ context.Context, req *tg.StatsLoadAsyncGraphRequest) (tg.StatsGraphClass, error) {
if len(req.Token) > maxStatsGraphTokenLength {
return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil
}
return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil
}
func (r *Router) onStatsGetStoryStats(ctx context.Context, req *tg.StatsGetStoryStatsRequest) (*tg.StatsStoryStats, error) {
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if err := r.validateStatsPeer(ctx, req.Peer); err != nil {
return nil, err
}
return &tg.StatsStoryStats{
ViewsGraph: r.emptyStatsGraph("Views"),
ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"),
}, nil
}
func (r *Router) onStatsGetStoryPublicForwards(ctx context.Context, req *tg.StatsGetStoryPublicForwardsRequest) (*tg.StatsPublicForwards, error) {
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength {
return nil, limitInvalidErr()
}
if err := r.validateStatsPeer(ctx, req.Peer); err != nil {
return nil, err
}
return emptyStatsPublicForwards(), nil
}
func (r *Router) onStatsGetPollStats(ctx context.Context, req *tg.StatsGetPollStatsRequest) (*tg.StatsPollStats, error) {
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if err := r.validateStatsPeer(ctx, req.Peer); err != nil {
return nil, err
}
return &tg.StatsPollStats{VotesGraph: r.emptyStatsGraph("Votes")}, nil
}
func (r *Router) statsChannelView(ctx context.Context, input tg.InputChannelClass) (domain.ChannelView, error) {
_, view, err := r.channelView(ctx, input)
if err != nil {
return domain.ChannelView{}, err
}
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
return domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED")
}
return view, nil
}
func (r *Router) validateStatsPeer(ctx context.Context, peer tg.InputPeerClass) error {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return internalErr()
}
_, err = r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
return err
}
func (r *Router) emptyBroadcastStats() *tg.StatsBroadcastStats {
return &tg.StatsBroadcastStats{
Period: r.statsDateRange(),
Followers: tg.StatsAbsValueAndPrev{},
ViewsPerPost: tg.StatsAbsValueAndPrev{},
SharesPerPost: tg.StatsAbsValueAndPrev{},
ReactionsPerPost: tg.StatsAbsValueAndPrev{},
ViewsPerStory: tg.StatsAbsValueAndPrev{},
SharesPerStory: tg.StatsAbsValueAndPrev{},
ReactionsPerStory: tg.StatsAbsValueAndPrev{},
EnabledNotifications: tg.StatsPercentValue{},
GrowthGraph: r.emptyStatsGraph("Growth"),
FollowersGraph: r.emptyStatsGraph("Followers"),
MuteGraph: r.emptyStatsGraph("Muted"),
TopHoursGraph: r.emptyStatsGraph("Hours"),
InteractionsGraph: r.emptyStatsGraph("Interactions"),
IvInteractionsGraph: r.emptyStatsGraph("Instant Views"),
ViewsBySourceGraph: r.emptyStatsGraph("Views"),
NewFollowersBySourceGraph: r.emptyStatsGraph("Followers"),
LanguagesGraph: r.emptyStatsGraph("Languages"),
ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"),
StoryInteractionsGraph: r.emptyStatsGraph("Stories"),
StoryReactionsByEmotionGraph: r.emptyStatsGraph("Story Reactions"),
}
}
func (r *Router) emptyMegagroupStats() *tg.StatsMegagroupStats {
return &tg.StatsMegagroupStats{
Period: r.statsDateRange(),
Members: tg.StatsAbsValueAndPrev{},
Messages: tg.StatsAbsValueAndPrev{},
Viewers: tg.StatsAbsValueAndPrev{},
Posters: tg.StatsAbsValueAndPrev{},
GrowthGraph: r.emptyStatsGraph("Growth"),
MembersGraph: r.emptyStatsGraph("Members"),
NewMembersBySourceGraph: r.emptyStatsGraph("Members"),
LanguagesGraph: r.emptyStatsGraph("Languages"),
MessagesGraph: r.emptyStatsGraph("Messages"),
ActionsGraph: r.emptyStatsGraph("Actions"),
TopHoursGraph: r.emptyStatsGraph("Hours"),
WeekdaysGraph: r.emptyStatsGraph("Weekdays"),
TopPosters: []tg.StatsGroupTopPoster{},
TopAdmins: []tg.StatsGroupTopAdmin{},
TopInviters: []tg.StatsGroupTopInviter{},
}
}
func (r *Router) statsDateRange() tg.StatsDateRangeDays {
now := int(r.clock.Now().Unix())
return tg.StatsDateRangeDays{MinDate: now - 86400, MaxDate: now}
}
func (r *Router) emptyStatsGraph(label string) *tg.StatsGraph {
nowMillis := r.clock.Now().UnixMilli()
prevMillis := nowMillis - 86400000
data := fmt.Sprintf(
`{"columns":[["x",%d,%d],["y0",0,0]],"types":{"x":"x","y0":"line"},"names":{"y0":%q},"colors":{"y0":"blue#4a90e2"}}`,
prevMillis,
nowMillis,
label,
)
return &tg.StatsGraph{JSON: tg.DataJSON{Data: data}}
}
func emptyStatsPublicForwards() *tg.StatsPublicForwards {
return &tg.StatsPublicForwards{
Count: 0,
Forwards: []tg.PublicForwardClass{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}

148
internal/rpc/stickers.go Normal file
View file

@ -0,0 +1,148 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// 本文件把 reaction / sticker 资源 RPC 接到真实 seed 数据documents / sticker_sets /
// available_reactionsFiles 服务缺失或资源未导入时回退到 tdesktop 兼容 stub。
func (r *Router) onMessagesGetAvailableReactions(ctx context.Context, hash int) (tg.MessagesAvailableReactionsClass, error) {
if r.deps.Files == nil {
return tdesktop.AvailableReactions(hash), nil
}
reactions, err := r.deps.Files.ListAvailableReactions(ctx)
if err != nil {
return nil, internalErr()
}
if len(reactions) == 0 {
return tdesktop.AvailableReactions(hash), nil
}
catalogHash := availableReactionsHash(reactions)
if hash == catalogHash {
return &tg.MessagesAvailableReactionsNotModified{}, nil
}
docs, err := r.deps.Files.GetDocuments(ctx, reactionDocumentIDs(reactions))
if err != nil {
return nil, internalErr()
}
return tgAvailableReactions(reactions, documentsByID(docs), catalogHash), nil
}
func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGetStickerSetRequest) (tg.MessagesStickerSetClass, error) {
if r.deps.Files == nil {
return tdesktop.StickerSet(req), nil
}
ref, ok := stickerSetRefFromInput(req.Stickerset)
if !ok {
return tdesktop.StickerSet(req), nil
}
set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, ref)
if err != nil {
return nil, internalErr()
}
if !found {
// 未 seed 的系统集 / 未知短名:回退兼容 stub避免破坏客户端。
return tdesktop.StickerSet(req), nil
}
if req.Hash != 0 && req.Hash == set.Hash {
return &tg.MessagesStickerSetNotModified{}, nil
}
return tgMessagesStickerSet(set, docs), nil
}
func (r *Router) onMessagesGetAllStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) {
return r.allStickersForKind(ctx, hash, domain.StickerSetKindStickers)
}
func (r *Router) onMessagesGetEmojiStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) {
return r.allStickersForKind(ctx, hash, domain.StickerSetKindEmoji)
}
func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain.StickerSetKind) (tg.MessagesAllStickersClass, error) {
if r.deps.Files == nil {
return messagesAllStickersEmpty(hash), nil
}
sets, err := r.deps.Files.ListStickerSets(ctx, kind)
if err != nil {
return nil, internalErr()
}
if len(sets) == 0 {
return messagesAllStickersEmpty(hash), nil
}
catalogHash := stickerSetsCatalogHash(sets)
if hash == catalogHash {
return &tg.MessagesAllStickersNotModified{}, nil
}
return &tg.MessagesAllStickers{Hash: catalogHash, Sets: tgStickerSets(sets)}, nil
}
func documentsByID(docs []domain.Document) map[int64]domain.Document {
m := make(map[int64]domain.Document, len(docs))
for _, d := range docs {
m[d.ID] = d
}
return m
}
// availableReactionsHash 用 reaction 的核心字段算稳定 hash供 *NotModified 缓存判定)。
func availableReactionsHash(reactions []domain.AvailableReaction) int {
values := make([]int64, 0, len(reactions)*10)
for _, r := range reactions {
values = append(values,
int64(len([]rune(r.Reaction))),
boolHashValue(r.Inactive),
boolHashValue(r.Premium),
r.StaticIconID,
r.AppearAnimationID,
r.SelectAnimationID,
r.ActivateAnimationID,
r.EffectAnimationID,
r.AroundAnimationID,
r.CenterIconID,
)
}
return int(tdesktopCountHash(values) & 0x7fffffff)
}
func stickerSetsCatalogHash(sets []domain.StickerSet) int64 {
values := make([]int64, 0, len(sets))
for _, set := range sets {
if set.ID == 0 {
return 0
}
if set.Archived {
continue
}
values = append(values, int64(set.Hash))
}
return int64(tdesktopCountHash(values))
}
func boolHashValue(v bool) int64 {
if v {
return 1
}
return 0
}
func tdesktopCountHash(values []int64) uint64 {
var hash uint64
for _, value := range values {
hash = tdesktopHashUpdate(hash, value)
}
return hash
}
func tdesktopHashUpdate(hash uint64, value int64) uint64 {
hash ^= hash >> 21
hash ^= hash << 35
hash ^= hash >> 4
hash += uint64(value)
return hash
}

View file

@ -0,0 +1,188 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func TestStickerSetsCatalogHashMatchesTDesktopFormula(t *testing.T) {
sets := []domain.StickerSet{
{ID: 10, Hash: 123},
{ID: 11, Hash: 456},
}
got := stickerSetsCatalogHash(sets)
const want int64 = 4284229878340
if got != want {
t.Fatalf("stickerSetsCatalogHash() = %d, want %d", got, want)
}
if old := mediaCatalogHash([]int64{10, 123, 11, 456}); old == got {
t.Fatalf("test fixture no longer distinguishes old media hash from TDesktop hash: %d", got)
}
}
func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) {
ctx := context.Background()
files := &fakeFiles{
sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindStickers: {
{
ID: 10,
AccessHash: 100,
ShortName: "one",
Title: "One",
Count: 1,
Hash: 123,
Installed: true,
},
{
ID: 11,
AccessHash: 110,
ShortName: "two",
Title: "Two",
Count: 1,
Hash: 456,
Installed: true,
},
},
},
}
r := &Router{deps: Deps{Files: files}}
first, err := r.onMessagesGetAllStickers(ctx, 0)
if err != nil {
t.Fatalf("first getAllStickers: %v", err)
}
full, ok := first.(*tg.MessagesAllStickers)
if !ok {
t.Fatalf("first getAllStickers = %T, want *tg.MessagesAllStickers", first)
}
const wantHash int64 = 4284229878340
if full.Hash != wantHash {
t.Fatalf("first hash = %d, want %d", full.Hash, wantHash)
}
second, err := r.onMessagesGetAllStickers(ctx, full.Hash)
if err != nil {
t.Fatalf("second getAllStickers: %v", err)
}
if _, ok := second.(*tg.MessagesAllStickersNotModified); !ok {
t.Fatalf("second getAllStickers = %T, want *tg.MessagesAllStickersNotModified", second)
}
}
func TestMessagesGetAvailableReactionsNotModified(t *testing.T) {
ctx := context.Background()
reactions := []domain.AvailableReaction{
{
Reaction: "👍",
Title: "Like",
StaticIconID: 101,
AppearAnimationID: 102,
SelectAnimationID: 103,
ActivateAnimationID: 104,
EffectAnimationID: 105,
AroundAnimationID: 106,
CenterIconID: 107,
},
}
files := &fakeFiles{reactions: reactions}
r := &Router{deps: Deps{Files: files}}
first, err := r.onMessagesGetAvailableReactions(ctx, 0)
if err != nil {
t.Fatalf("first getAvailableReactions: %v", err)
}
full, ok := first.(*tg.MessagesAvailableReactions)
if !ok {
t.Fatalf("first getAvailableReactions = %T, want *tg.MessagesAvailableReactions", first)
}
if full.Hash == 0 {
t.Fatal("first getAvailableReactions returned zero hash")
}
second, err := r.onMessagesGetAvailableReactions(ctx, full.Hash)
if err != nil {
t.Fatalf("second getAvailableReactions: %v", err)
}
if _, ok := second.(*tg.MessagesAvailableReactionsNotModified); !ok {
t.Fatalf("second getAvailableReactions = %T, want *tg.MessagesAvailableReactionsNotModified", second)
}
}
func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) {
doc := tgDocument(domain.Document{
ID: 100,
AccessHash: 1,
DCID: 2,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{
{Kind: domain.PhotoSizeKindCached, Type: "m", W: 128, H: 128, Bytes: []byte("webp")},
},
})
full, ok := doc.(*tg.Document)
if !ok {
t.Fatalf("tgDocument = %T, want *tg.Document", doc)
}
if len(full.Thumbs) != 1 {
t.Fatalf("thumbs = %d, want 1", len(full.Thumbs))
}
size, ok := full.Thumbs[0].(*tg.PhotoSize)
if !ok {
t.Fatalf("thumb = %T, want *tg.PhotoSize", full.Thumbs[0])
}
if size.Type != "m" || size.W != 128 || size.H != 128 || size.Size != 4 {
t.Fatalf("thumb size = %+v, want downloadable m 128x128 size=4", size)
}
}
func TestTGDocumentUsesDomainDocumentID(t *testing.T) {
const documentID int64 = 1382305375846410902
doc := tgDocument(domain.Document{
ID: documentID,
AccessHash: 1,
DCID: 2,
MimeType: "application/x-tgsticker",
})
full, ok := doc.(*tg.Document)
if !ok {
t.Fatalf("tgDocument = %T, want *tg.Document", doc)
}
if full.ID != documentID {
t.Fatalf("document id = %d, want %d", full.ID, documentID)
}
}
func TestMessagesGetCustomEmojiDocumentsUsesDomainIDs(t *testing.T) {
const documentID int64 = 1382305375846410902
ctx := WithUserID(context.Background(), 1780269504)
r := &Router{deps: Deps{Files: &fakeFiles{
docs: map[int64]domain.Document{
documentID: {
ID: documentID,
AccessHash: 1,
DCID: 2,
MimeType: "application/x-tgsticker",
},
},
}}}
docs, err := r.onMessagesGetCustomEmojiDocuments(ctx, []int64{documentID})
if err != nil {
t.Fatalf("getCustomEmojiDocuments: %v", err)
}
if len(docs) != 1 {
t.Fatalf("docs = %d, want 1", len(docs))
}
doc, ok := docs[0].(*tg.Document)
if !ok {
t.Fatalf("doc = %T, want *tg.Document", docs[0])
}
if doc.ID != documentID {
t.Fatalf("doc id = %d, want %d", doc.ID, documentID)
}
}

49
internal/rpc/stories.go Normal file
View file

@ -0,0 +1,49 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// registerStories 注册第一阶段 TDesktop 启动所需 stories.* RPC 兼容响应。
func (r *Router) registerStories(d *tg.ServerDispatcher) {
d.OnStoriesGetAllStories(func(ctx context.Context, req *tg.StoriesGetAllStoriesRequest) (tg.StoriesAllStoriesClass, error) {
return tdesktop.AllStories(), nil
})
d.OnStoriesGetStoriesArchive(func(ctx context.Context, req *tg.StoriesGetStoriesArchiveRequest) (*tg.StoriesStories, error) {
return tdesktop.StoriesArchive(), nil
})
d.OnStoriesGetPinnedStories(func(ctx context.Context, req *tg.StoriesGetPinnedStoriesRequest) (*tg.StoriesStories, error) {
return tdesktop.PinnedStories(), nil
})
d.OnStoriesGetAlbums(func(ctx context.Context, req *tg.StoriesGetAlbumsRequest) (tg.StoriesAlbumsClass, error) {
return tdesktop.StoryAlbums(), nil
})
d.OnStoriesSendReaction(r.onStoriesSendReaction)
}
func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendReactionRequest) (tg.UpdatesClass, error) {
if req.StoryID <= 0 || req.StoryID > domain.MaxMessageBoxID {
return nil, storyIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
if err := validateReactionClass(req.Reaction); err != nil {
return nil, err
}
if parsed, err := domainMessageReactionFromTL(req.Reaction); err == nil {
if err := r.recordMessageReactionUse(ctx, userID, []domain.MessageReaction{parsed}, req.GetAddToRecent(), int(r.clock.Now().Unix())); err != nil {
return nil, internalErr()
}
}
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}

View file

@ -0,0 +1,283 @@
package rpc
import (
"context"
"telesrv/internal/domain"
)
func (r *Router) enrichUpdateEvents(ctx context.Context, viewerUserID int64, events []domain.UpdateEvent) []domain.UpdateEvent {
if len(events) == 0 {
return events
}
out := append([]domain.UpdateEvent(nil), events...)
for i := range out {
if out[i].Type == domain.UpdateEventMessageReactions {
out[i] = r.enrichMessageReactionEvent(ctx, viewerUserID, out[i])
}
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
addDomainPeerRef(out[i].Peer, 0, userIDs, channelIDs)
for _, peer := range out[i].Peers {
addDomainPeerRef(peer, 0, userIDs, channelIDs)
}
collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs)
out[i].Users = r.withUsersPresence(mergeDomainUsers(out[i].Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...))
out[i].Channels = mergeDomainChannels(out[i].Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...)
}
return out
}
func (r *Router) enrichMessageReactionEvent(ctx context.Context, viewerUserID int64, event domain.UpdateEvent) domain.UpdateEvent {
if r.deps.Messages == nil || event.Message.ID <= 0 {
return event
}
peer := event.Message.Peer
if peer.Type == "" || peer.ID == 0 {
peer = event.Peer
}
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return event
}
res, err := r.deps.Messages.GetMessageReactions(ctx, viewerUserID, domain.PrivateMessageReactionsRequest{
OwnerUserID: viewerUserID,
Peer: peer,
IDs: []int{event.Message.ID},
})
if err != nil {
return event
}
for _, msg := range res.Messages {
if msg.OwnerUserID == viewerUserID && msg.ID == event.Message.ID {
msg.Pts = event.Pts
event.Message = msg
event.Peer = msg.Peer
return event
}
}
return event
}
func (r *Router) enrichChannelDifference(ctx context.Context, viewerUserID int64, diff domain.ChannelDifference) domain.ChannelDifference {
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
for _, event := range diff.Events {
collectChannelUpdatePeerRefs(event, diff.Channel.ID, userIDs, channelIDs)
}
for _, msg := range diff.NewMessages {
collectChannelMessagePeerRefs(msg, diff.Channel.ID, userIDs, channelIDs)
}
for _, event := range diff.OtherUpdates {
collectChannelUpdatePeerRefs(event, diff.Channel.ID, userIDs, channelIDs)
}
diff.Users = r.withUsersPresence(mergeDomainUsers(diff.Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...))
diff.Channels = mergeDomainChannels(diff.Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...)
return diff
}
func (r *Router) enrichChannelHistory(ctx context.Context, viewerUserID int64, history domain.ChannelHistory) domain.ChannelHistory {
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
for _, msg := range history.Messages {
collectChannelMessagePeerRefs(msg, history.Channel.ID, userIDs, channelIDs)
}
for _, topic := range history.Topics {
if topic.CreatorUserID != 0 {
userIDs[topic.CreatorUserID] = struct{}{}
}
}
history.Users = r.withUsersPresence(mergeDomainUsers(history.Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...))
history.Channels = mergeDomainChannels(history.Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...)
return history
}
func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
addDomainPeerRef(msg.From, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(msg.Peer, currentChannelID, userIDs, channelIDs)
if msg.Forward != nil {
addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs)
}
if msg.ReplyTo != nil {
addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs)
}
if msg.Reactions != nil {
for _, reaction := range msg.Reactions.Recent {
if reaction.UserID != 0 {
userIDs[reaction.UserID] = struct{}{}
}
}
}
}
func collectChannelUpdatePeerRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
if event.SenderUserID != 0 {
userIDs[event.SenderUserID] = struct{}{}
}
for _, id := range event.UserIDs {
if id != 0 {
userIDs[id] = struct{}{}
}
}
for _, member := range []domain.ChannelMember{event.Previous, event.Participant} {
if member.UserID != 0 {
userIDs[member.UserID] = struct{}{}
}
if member.InviterUserID != 0 {
userIDs[member.InviterUserID] = struct{}{}
}
}
collectChannelMessagePeerRefs(event.Message, currentChannelID, userIDs, channelIDs)
}
func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
if msg.SenderUserID != 0 {
userIDs[msg.SenderUserID] = struct{}{}
}
addDomainPeerRef(msg.From, currentChannelID, userIDs, channelIDs)
if msg.SendAs != nil {
addDomainPeerRef(*msg.SendAs, currentChannelID, userIDs, channelIDs)
}
if msg.Forward != nil {
addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs)
}
if msg.ReplyTo != nil {
addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs)
}
if msg.Action != nil {
for _, id := range msg.Action.UserIDs {
if id != 0 {
userIDs[id] = struct{}{}
}
}
}
if msg.Reactions != nil {
for _, reaction := range msg.Reactions.Recent {
if reaction.UserID != 0 {
userIDs[reaction.UserID] = struct{}{}
}
}
}
}
func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID != 0 {
userIDs[peer.ID] = struct{}{}
}
case domain.PeerTypeChannel:
if peer.ID != 0 && peer.ID != currentChannelID {
channelIDs[peer.ID] = struct{}{}
}
}
}
func (r *Router) domainUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []domain.User {
if len(ids) == 0 {
return nil
}
out := make([]domain.User, 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{}{}
switch {
case id == domain.OfficialSystemUserID:
out = append(out, r.withUserPresence(domain.OfficialSystemUser()))
case r.deps.Users == nil:
continue
case id == currentUserID:
if u, err := r.deps.Users.Self(ctx, currentUserID); err == nil && u.ID != 0 {
out = append(out, r.withUserPresence(u))
}
default:
if u, found, err := r.deps.Users.ByID(ctx, currentUserID, id); err == nil && found {
out = append(out, r.withUserPresence(u))
}
}
}
return out
}
func (r *Router) domainChannelsForIDs(ctx context.Context, currentUserID int64, ids []int64) []domain.Channel {
if r.deps.Channels == nil || len(ids) == 0 {
return nil
}
out := make([]domain.Channel, 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{}{}
view, err := r.deps.Channels.GetChannel(ctx, currentUserID, id)
if err != nil || view.Channel.ID == 0 {
continue
}
out = append(out, view.Channel)
}
return out
}
func mergeDomainUsers(base []domain.User, extra ...domain.User) []domain.User {
out := append([]domain.User(nil), base...)
seen := make(map[int64]struct{}, len(out)+len(extra))
for _, u := range out {
if u.ID != 0 {
seen[u.ID] = struct{}{}
}
}
for _, u := range extra {
if u.ID == 0 {
continue
}
if _, ok := seen[u.ID]; ok {
continue
}
seen[u.ID] = struct{}{}
out = append(out, u)
}
return out
}
func mergeDomainChannels(base []domain.Channel, extra ...domain.Channel) []domain.Channel {
out := append([]domain.Channel(nil), base...)
seen := make(map[int64]struct{}, len(out)+len(extra))
for _, ch := range out {
if ch.ID != 0 {
seen[ch.ID] = struct{}{}
}
}
for _, ch := range extra {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
out = append(out, ch)
}
return out
}
func mapKeys(items map[int64]struct{}) []int64 {
if len(items) == 0 {
return nil
}
out := make([]int64, 0, len(items))
for id := range items {
if id != 0 {
out = append(out, id)
}
}
return out
}

107
internal/rpc/updates.go Normal file
View file

@ -0,0 +1,107 @@
package rpc
import (
"context"
"time"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
const updatesTooLongNudgeDelay = 300 * time.Millisecond
// registerUpdates 注册 updates.* RPC handler。
func (r *Router) registerUpdates(d *tg.ServerDispatcher) {
d.OnUpdatesGetState(r.onUpdatesGetState)
d.OnUpdatesGetDifference(r.onUpdatesGetDifference)
}
// onUpdatesGetState 处理 updates.getState第一阶段返回零状态
func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Updates == nil {
r.markSessionReceivesUpdates(ctx, userID)
return &tg.UpdatesState{Date: int(r.clock.Now().Unix())}, nil
}
st, err := r.deps.Updates.GetState(ctx, id, userID)
if err != nil {
return nil, internalErr()
}
current, err := r.deps.Updates.CurrentState(ctx, userID)
if err != nil {
return nil, internalErr()
}
r.markSessionReceivesUpdates(ctx, userID)
if current.Pts > st.Pts {
r.scheduleCurrentSessionDifferenceNudge(ctx)
}
return ptr(tgUpdateState(st)), nil
}
func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetDifferenceRequest) (tg.UpdatesDifferenceClass, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Updates == nil {
now := int(r.clock.Now().Unix())
r.markSessionReceivesUpdates(ctx, userID)
return &tg.UpdatesDifferenceEmpty{Date: now}, nil
}
st, err := r.deps.Updates.GetDifference(ctx, id, userID, domain.UpdateState{
Pts: req.Pts,
Qts: req.Qts,
Date: req.Date,
})
if err != nil {
return nil, internalErr()
}
r.markSessionReceivesUpdates(ctx, userID)
if len(st.Events) == 0 {
return &tg.UpdatesDifferenceEmpty{Date: st.State.Date, Seq: st.State.Seq}, nil
}
st.Events = r.enrichUpdateEvents(ctx, userID, st.Events)
return tgUpdatesDifference(st), nil
}
func (r *Router) markSessionReceivesUpdates(ctx context.Context, userID int64) {
if r.deps.Sessions == nil {
return
}
r.syncSessionChannelMemberships(ctx, userID)
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
if scoped, ok := r.scopedSessions(); ok {
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
scoped.SetReceivesUpdatesForAuthKey(rawAuthKeyID, sessionID, true)
return
}
}
r.deps.Sessions.SetReceivesUpdates(sessionID, true)
}
func (r *Router) scheduleCurrentSessionDifferenceNudge(ctx context.Context) {
pushCtx := context.Background()
if sessionID, ok := SessionIDFrom(ctx); ok {
pushCtx = WithSessionID(pushCtx, sessionID)
}
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
pushCtx = WithRawAuthKeyID(pushCtx, rawAuthKeyID)
}
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
pushCtx = WithAuthKeyID(pushCtx, authKeyID)
}
time.AfterFunc(updatesTooLongNudgeDelay, func() {
r.pushCurrentSessionMessage(pushCtx, "push updatesTooLong after getState", &tg.UpdatesTooLong{})
})
}
func ptr[T any](v T) *T { return &v }

188
internal/rpc/upload.go Normal file
View file

@ -0,0 +1,188 @@
package rpc
import (
"context"
"errors"
"fmt"
"strings"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// registerUpload 注册 upload.* RPC handler分片上传 + 文件下载)。
func (r *Router) registerUpload(d *tg.ServerDispatcher) {
d.OnUploadSaveFilePart(r.onUploadSaveFilePart)
d.OnUploadSaveBigFilePart(r.onUploadSaveBigFilePart)
d.OnUploadGetFile(r.onUploadGetFile)
d.OnUploadGetFileHashes(r.onUploadGetFileHashes)
}
func (r *Router) onUploadSaveFilePart(ctx context.Context, req *tg.UploadSaveFilePartRequest) (bool, error) {
if r.deps.Files == nil {
return false, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !ok || userID == 0 {
return false, fileIDInvalidErr()
}
if req.FilePart < 0 {
return false, filePartInvalidErr()
}
saved, err := r.deps.Files.SaveFilePart(ctx, userID, req.FileID, req.FilePart, req.Bytes)
if err != nil {
return false, fileSaveErr(err)
}
return saved, nil
}
func (r *Router) onUploadSaveBigFilePart(ctx context.Context, req *tg.UploadSaveBigFilePartRequest) (bool, error) {
if r.deps.Files == nil {
return false, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !ok || userID == 0 {
return false, fileIDInvalidErr()
}
if req.FilePart < 0 {
return false, filePartInvalidErr()
}
saved, err := r.deps.Files.SaveBigFilePart(ctx, userID, req.FileID, req.FilePart, req.FileTotalParts, req.Bytes)
if err != nil {
return false, fileSaveErr(err)
}
return saved, nil
}
func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
key, ok := fileLocationKey(req.Location)
if !ok {
return nil, locationInvalidErr()
}
chunk, found, err := r.deps.Files.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: key,
Offset: req.Offset,
Limit: req.Limit,
})
if err != nil {
return nil, internalErr()
}
if found {
return &tg.UploadFile{
Type: storageFileType(chunk.MimeType, chunk.Bytes),
Mtime: 0,
Bytes: chunk.Bytes,
}, nil
}
return nil, locationInvalidErr()
}
// onUploadGetFileHashes 返回空 hash 列表:本阶段不做 CDN/分片完整性校验,客户端据空列表直接信任数据。
func (r *Router) onUploadGetFileHashes(ctx context.Context, req *tg.UploadGetFileHashesRequest) ([]tg.FileHash, error) {
return []tg.FileHash{}, nil
}
// fileLocationKey 把 tg.InputFileLocation 推导为 file_blobs 的 location_key。
// 约定:
//
// doc:<id> 文档主体
// doc:<id>:<type> 文档缩略图
// photo:<id>:<type> 照片某尺寸(头像 big→c / small→a
func fileLocationKey(location tg.InputFileLocationClass) (string, bool) {
switch loc := location.(type) {
case *tg.InputDocumentFileLocation:
if loc.ID == 0 {
return "", false
}
if loc.ThumbSize == "" {
return fmt.Sprintf("doc:%d", loc.ID), true
}
return fmt.Sprintf("doc:%d:%s", loc.ID, loc.ThumbSize), true
case *tg.InputPhotoFileLocation:
if loc.ID == 0 || loc.ThumbSize == "" {
return "", false
}
return fmt.Sprintf("photo:%d:%s", loc.ID, loc.ThumbSize), true
case *tg.InputPeerPhotoFileLocation:
if loc.PhotoID == 0 {
return "", false
}
size := "a"
if loc.Big {
size = "c"
}
return fmt.Sprintf("photo:%d:%s", loc.PhotoID, size), true
default:
// InputFileLocation(legacy volume/local/secret) / InputStickerSetThumb 等本阶段不生成对应资源。
return "", false
}
}
// storageFileType 映射 storage.FileType优先信任字节魔数以兼容历史上写错 mime 的 seed blob。
func storageFileType(mime string, data []byte) tg.StorageFileTypeClass {
switch sniffImageType(data) {
case "jpeg":
return &tg.StorageFileJpeg{}
case "png":
return &tg.StorageFilePng{}
case "gif":
return &tg.StorageFileGif{}
case "webp":
return &tg.StorageFileWebp{}
}
switch {
case strings.Contains(mime, "webp"):
return &tg.StorageFileWebp{}
case strings.Contains(mime, "jpeg"), strings.Contains(mime, "jpg"):
return &tg.StorageFileJpeg{}
case strings.Contains(mime, "png"):
return &tg.StorageFilePng{}
case strings.Contains(mime, "gif"):
return &tg.StorageFileGif{}
case strings.Contains(mime, "mp4"), strings.Contains(mime, "quicktime"), strings.Contains(mime, "video"):
return &tg.StorageFileMov{}
}
return &tg.StorageFileUnknown{}
}
// sniffImageType 用魔数探测常见图片类型。
func sniffImageType(data []byte) string {
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpeg"
}
if len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' {
return "png"
}
if len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' {
return "gif"
}
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
return "webp"
}
return ""
}
// fileSaveErr 把 files 服务的分片错误映射为 rpc_error。
func fileSaveErr(err error) error {
switch {
case errors.Is(err, domain.ErrFilePartInvalid):
return filePartInvalidErr()
case errors.Is(err, domain.ErrFilePartsInvalid):
return filePartsInvalidErr()
case errors.Is(err, domain.ErrFilePartTooBig):
return filePartTooBigErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,34 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
)
func TestStorageFileTypePrefersMagicOverMime(t *testing.T) {
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
if _, ok := storageFileType("image/jpeg", webp).(*tg.StorageFileWebp); !ok {
t.Fatalf("webp bytes mislabeled as jpeg should return StorageFileWebp")
}
}
func TestStorageFileTypeFallsBackToMime(t *testing.T) {
if _, ok := storageFileType("image/png", nil).(*tg.StorageFilePng); !ok {
t.Fatalf("png mime without bytes should return StorageFilePng")
}
}
func TestFileLocationKeyUsesDocumentID(t *testing.T) {
key, ok := fileLocationKey(&tg.InputDocumentFileLocation{
ID: 1382305375846410902,
ThumbSize: "m",
})
if !ok {
t.Fatal("fileLocationKey returned !ok")
}
const want = "doc:1382305375846410902:m"
if key != want {
t.Fatalf("key = %q, want %q", key, want)
}
}

187
internal/rpc/users.go Normal file
View file

@ -0,0 +1,187 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/app/users"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
const maxSavedMusicLimit = 100
// registerUsers 注册 users.* RPC handler。
func (r *Router) registerUsers(d *tg.ServerDispatcher) {
d.OnUsersGetUsers(r.onUsersGetUsers)
d.OnUsersGetFullUser(r.onUsersGetFullUser)
d.OnUsersGetSavedMusic(r.onUsersGetSavedMusic)
d.OnUsersGetSavedMusicByID(r.onUsersGetSavedMusicByID)
}
// onUsersGetUsers 处理 users.getUsers支持 self 和已知 user peer含 777000 官方账号)。
func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) ([]tg.UserClass, error) {
currentUserID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
out := make([]tg.UserClass, 0, len(ids))
for _, in := range ids {
if r.deps.Users == nil {
continue
}
switch v := in.(type) {
case *tg.InputUserSelf:
if !authorized {
continue
}
u, err := r.deps.Users.Self(ctx, currentUserID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
continue // 未登录getUsers 尽力而为,跳过 self
}
return nil, internalErr()
}
out = append(out, r.tgSelfUser(u))
case *tg.InputUser:
if !authorized {
continue
}
u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
continue
}
return nil, internalErr()
}
if !found || (v.AccessHash != 0 && v.AccessHash != u.AccessHash) {
continue
}
out = append(out, r.tgUser(u))
}
}
return out, nil
}
func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (*tg.UsersUserFull, error) {
if r.deps.Users == nil {
return emptyUserFull(), nil
}
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
u, found, err := r.userFromInput(ctx, currentUserID, id)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
return emptyUserFull(), nil
}
return nil, internalErr()
}
if !found {
return emptyUserFull(), nil
}
user := r.tgUser(u)
if _, ok := id.(*tg.InputUserSelf); ok {
user = r.tgSelfUser(u)
}
full := tg.UserFull{
ID: u.ID,
About: u.About,
Settings: tg.PeerSettings{},
NotifySettings: *tdesktop.NotifySettings(),
}
if r.deps.Channels != nil && u.ID != currentUserID {
common, err := r.deps.Channels.CommonChannels(ctx, currentUserID, domain.CommonChannelsRequest{
UserID: currentUserID,
TargetUserID: u.ID,
Limit: 1,
CountOnly: true,
})
if err != nil {
return nil, internalErr()
}
full.CommonChatsCount = common.Count
}
return &tg.UsersUserFull{
FullUser: full,
Users: []tg.UserClass{user},
}, nil
}
func (r *Router) onUsersGetSavedMusic(ctx context.Context, req *tg.UsersGetSavedMusicRequest) (tg.UsersSavedMusicClass, error) {
if req == nil || req.Offset < 0 || req.Limit < 0 || req.Limit > maxSavedMusicLimit {
return nil, limitInvalidErr()
}
if err := r.validateInputUser(ctx, req.ID); err != nil {
return nil, err
}
return &tg.UsersSavedMusic{
Count: 0,
Documents: []tg.DocumentClass{},
}, nil
}
func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetSavedMusicByIDRequest) (tg.UsersSavedMusicClass, error) {
if req == nil || len(req.Documents) > maxSavedMusicLimit {
return nil, limitInvalidErr()
}
if err := r.validateInputUser(ctx, req.ID); err != nil {
return nil, err
}
return &tg.UsersSavedMusic{
Count: 0,
Documents: []tg.DocumentClass{},
}, nil
}
func emptyUserFull() *tg.UsersUserFull {
return &tg.UsersUserFull{
FullUser: tg.UserFull{
Settings: tg.PeerSettings{},
NotifySettings: *tdesktop.NotifySettings(),
},
}
}
func (r *Router) userFromInput(ctx context.Context, currentUserID int64, id tg.InputUserClass) (domain.User, bool, error) {
switch v := id.(type) {
case *tg.InputUserSelf:
u, err := r.deps.Users.Self(ctx, currentUserID)
return u, err == nil, err
case *tg.InputUser:
u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID)
if err != nil || !found {
return domain.User{}, found, err
}
if v.AccessHash != 0 && v.AccessHash != u.AccessHash {
return domain.User{}, false, nil
}
return u, true, nil
default:
return domain.User{}, false, nil
}
}
func (r *Router) validateInputUser(ctx context.Context, id tg.InputUserClass) error {
if r.deps.Users == nil {
return nil
}
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return internalErr()
}
_, found, err := r.userFromInput(ctx, currentUserID, id)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
return userIDInvalidErr()
}
return internalErr()
}
if !found {
return userIDInvalidErr()
}
return nil
}