Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
292
internal/rpc/account.go
Normal file
292
internal/rpc/account.go
Normal 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
16
internal/rpc/aicompose.go
Normal 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
347
internal/rpc/auth.go
Normal 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
|
||||
}
|
||||
182
internal/rpc/channel_interest.go
Normal file
182
internal/rpc/channel_interest.go
Normal 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
4017
internal/rpc/channels.go
Normal file
File diff suppressed because it is too large
Load diff
562
internal/rpc/contacts.go
Normal file
562
internal/rpc/contacts.go
Normal 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
96
internal/rpc/context.go
Normal 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_id;temp 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
1919
internal/rpc/convert.go
Normal file
File diff suppressed because it is too large
Load diff
385
internal/rpc/convert_media.go
Normal file
385
internal/rpc/convert_media.go
Normal 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.availableReactions;docByID 由 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.stickerSet(set + 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
333
internal/rpc/deps.go
Normal 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
5
internal/rpc/doc.go
Normal 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
194
internal/rpc/errors.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gotd/td/tgerr"
|
||||
|
||||
"telesrv/internal/app/auth"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 本文件集中构造所有 rpc_error:error_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
67
internal/rpc/folders.go
Normal 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
63
internal/rpc/help.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
)
|
||||
|
||||
// registerHelp 注册 help.* RPC handler(DC 配置、最近 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
41
internal/rpc/langpack.go
Normal 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
6365
internal/rpc/messages.go
Normal file
File diff suppressed because it is too large
Load diff
25
internal/rpc/metrics.go
Normal file
25
internal/rpc/metrics.go
Normal 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) {}
|
||||
369
internal/rpc/outbox_dispatcher.go
Normal file
369
internal/rpc/outbox_dispatcher.go
Normal 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 并发 claim:ClaimPending 用 FOR UPDATE SKIP LOCKED,worker 间认领不重叠。
|
||||
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 outbox;ctx 退出时全部停止并等待退出。
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
458
internal/rpc/outbox_dispatcher_test.go
Normal file
458
internal/rpc/outbox_dispatcher_test.go
Normal 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
22
internal/rpc/payments.go
Normal 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
250
internal/rpc/photos.go
Normal 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.Photo(nil=清除头像)。
|
||||
// 支持新上传(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()
|
||||
}
|
||||
}
|
||||
58
internal/rpc/photos_push_test.go
Normal file
58
internal/rpc/photos_push_test.go
Normal 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
142
internal/rpc/premium.go
Normal 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
490
internal/rpc/presence.go
Normal 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
73
internal/rpc/push.go
Normal 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
416
internal/rpc/router.go
Normal 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 handler(tg.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 handler(registerHelp/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 紧跟 layer,buffer 剩余即内层请求。
|
||||
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.Object(Encode/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
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
539
internal/rpc/send_media.go
Normal 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 反向转为 domain(InputMediaUploadedDocument 用)。
|
||||
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
|
||||
}
|
||||
238
internal/rpc/send_media_test.go
Normal file
238
internal/rpc/send_media_test.go
Normal 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
211
internal/rpc/stats.go
Normal 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
148
internal/rpc/stickers.go
Normal 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_reactions);Files 服务缺失或资源未导入时回退到 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
|
||||
}
|
||||
188
internal/rpc/stickers_test.go
Normal file
188
internal/rpc/stickers_test.go
Normal 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
49
internal/rpc/stories.go
Normal 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
|
||||
}
|
||||
283
internal/rpc/update_peer_refs.go
Normal file
283
internal/rpc/update_peer_refs.go
Normal 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
107
internal/rpc/updates.go
Normal 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
188
internal/rpc/upload.go
Normal 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()
|
||||
}
|
||||
}
|
||||
34
internal/rpc/upload_test.go
Normal file
34
internal/rpc/upload_test.go
Normal 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
187
internal/rpc/users.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue