Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
2
internal/app/account/doc.go
Normal file
2
internal/app/account/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package account 是账号安全与设置应用服务。
|
||||
package account
|
||||
158
internal/app/account/service.go
Normal file
158
internal/app/account/service.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand")
|
||||
|
||||
// Service 提供账号安全配置查询。
|
||||
type Service struct {
|
||||
passwords store.PasswordStore
|
||||
reactions store.AccountReactionSettingsStore
|
||||
}
|
||||
|
||||
// ServiceOption 调整 account 服务依赖。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithReactionSettings 注入账号级 reaction 设置持久化。
|
||||
func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.reactions = reactions
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{passwords: passwords}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。
|
||||
func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) {
|
||||
if s == nil || s.passwords == nil || userID == 0 {
|
||||
return defaultPasswordSettings(), nil
|
||||
}
|
||||
settings, found, err := s.passwords.GetByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.PasswordSettings{}, err
|
||||
}
|
||||
if !found {
|
||||
return defaultPasswordSettings(), nil
|
||||
}
|
||||
if len(settings.SecureRandom) == 0 {
|
||||
settings.SecureRandom = append([]byte(nil), defaultSecureRandom...)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func defaultPasswordSettings() domain.PasswordSettings {
|
||||
return domain.PasswordSettings{SecureRandom: append([]byte(nil), defaultSecureRandom...)}
|
||||
}
|
||||
|
||||
// GetReactionSettings returns account-level reaction preferences.
|
||||
func (s *Service) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) {
|
||||
if s == nil || s.reactions == nil || userID == 0 {
|
||||
return domain.DefaultAccountReactionSettings(), nil
|
||||
}
|
||||
settings, found, err := s.reactions.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.DefaultAccountReactionSettings(), nil
|
||||
}
|
||||
return normalizeReactionSettings(settings), nil
|
||||
}
|
||||
|
||||
// SetReactionsNotifySettings stores reaction notification preferences.
|
||||
func (s *Service) SetReactionsNotifySettings(ctx context.Context, userID int64, notify domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) {
|
||||
settings, err := s.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
settings.Notify = normalizeNotifySettings(notify)
|
||||
return s.saveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
// SetDefaultReaction stores the account default quick reaction.
|
||||
func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) {
|
||||
settings, err := s.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
if reaction.Type == "" || reaction.Emoticon == "" {
|
||||
reaction = domain.DefaultAccountReactionSettings().DefaultReaction
|
||||
}
|
||||
settings.DefaultReaction = reaction
|
||||
return s.saveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
// SetPaidReactionPrivacy stores the account default paid reaction privacy.
|
||||
func (s *Service) SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) {
|
||||
settings, err := s.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountReactionSettings{}, err
|
||||
}
|
||||
settings.PaidPrivacy = normalizePaidPrivacy(privacy)
|
||||
return s.saveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) (domain.AccountReactionSettings, error) {
|
||||
settings = normalizeReactionSettings(settings)
|
||||
if s == nil || s.reactions == nil || userID == 0 {
|
||||
return settings, nil
|
||||
}
|
||||
return settings, s.reactions.SaveReactionSettings(ctx, userID, settings)
|
||||
}
|
||||
|
||||
func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings {
|
||||
defaults := domain.DefaultAccountReactionSettings()
|
||||
settings.Notify = normalizeNotifySettings(settings.Notify)
|
||||
if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" {
|
||||
settings.DefaultReaction = defaults.DefaultReaction
|
||||
}
|
||||
settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy)
|
||||
return settings
|
||||
}
|
||||
|
||||
func normalizeNotifySettings(settings domain.ReactionsNotifySettings) domain.ReactionsNotifySettings {
|
||||
if !validNotifyFrom(settings.MessagesFrom) {
|
||||
settings.MessagesFrom = domain.ReactionNotifyFromContacts
|
||||
}
|
||||
if !validNotifyFrom(settings.StoriesFrom) {
|
||||
settings.StoriesFrom = domain.ReactionNotifyFromContacts
|
||||
}
|
||||
if !validNotifyFrom(settings.PollVotesFrom) {
|
||||
settings.PollVotesFrom = domain.ReactionNotifyFromContacts
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func validNotifyFrom(value domain.ReactionNotifyFrom) bool {
|
||||
switch value {
|
||||
case domain.ReactionNotifyFromNone, domain.ReactionNotifyFromContacts, domain.ReactionNotifyFromAll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePaidPrivacy(privacy domain.PaidReactionPrivacy) domain.PaidReactionPrivacy {
|
||||
switch privacy.Kind {
|
||||
case domain.PaidReactionPrivacyAnonymous:
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}
|
||||
case domain.PaidReactionPrivacyPeer:
|
||||
if privacy.Peer != nil && privacy.Peer.ID != 0 {
|
||||
peer := *privacy.Peer
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}
|
||||
}
|
||||
}
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}
|
||||
}
|
||||
5
internal/app/auth/doc.go
Normal file
5
internal/app/auth/doc.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。
|
||||
// 第一阶段用开发固定验证码,2FA 配置由 account 服务持久化查询。
|
||||
//
|
||||
// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。
|
||||
package auth
|
||||
362
internal/app/auth/service.go
Normal file
362
internal/app/auth/service.go
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gotd/ige"
|
||||
"github.com/gotd/td/bin"
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// 登录错误。
|
||||
var (
|
||||
ErrCodeExpired = errors.New("phone code expired or not found")
|
||||
ErrCodeInvalid = errors.New("phone code invalid")
|
||||
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
|
||||
)
|
||||
|
||||
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
auths store.AuthorizationStore
|
||||
codes store.CodeStore
|
||||
authKeys store.AuthKeyStore
|
||||
tempKeys store.TempAuthKeyBindingStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
fixedCode string
|
||||
codeTTL time.Duration
|
||||
}
|
||||
|
||||
// Option 调整登录服务的可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithLoginMessages 在登录成功后写入官方系统账号的登录消息与会话摘要。
|
||||
func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option {
|
||||
return func(s *Service) {
|
||||
s.messages = messages
|
||||
s.dialogs = dialogs
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建登录服务。fixedCode 为开发固定验证码。
|
||||
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
|
||||
if s.authKeys != nil {
|
||||
inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binding.TempSessionID = inner.TempSessionID
|
||||
}
|
||||
if s.tempKeys == nil {
|
||||
return nil
|
||||
}
|
||||
return s.tempKeys.Save(ctx, binding)
|
||||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
|
||||
func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) {
|
||||
if s == nil || s.tempKeys == nil {
|
||||
return [8]byte{}, false, nil
|
||||
}
|
||||
binding, found, err := s.tempKeys.GetByTemp(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return [8]byte{}, found, err
|
||||
}
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return [8]byte{}, false, nil
|
||||
}
|
||||
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
|
||||
}
|
||||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录时 found=false。
|
||||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return 0, found, err
|
||||
}
|
||||
return a.UserID, true, nil
|
||||
}
|
||||
|
||||
// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。
|
||||
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: normalizePhone(phone), Code: s.fixedCode}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// SignIn 校验验证码并尝试登录。
|
||||
// needSignUp=true 表示验证码正确但用户不存在,调用方应引导注册(此时不删验证码,留给 SignUp)。
|
||||
func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (u domain.User, loginMessage domain.Message, needSignUp bool, err error) {
|
||||
phone = normalizePhone(phone)
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone || rec.Code != code {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册
|
||||
}
|
||||
if err := s.bind(ctx, auth, existing.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return existing, loginMessage, false, nil
|
||||
}
|
||||
|
||||
// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。
|
||||
// signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。
|
||||
func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) {
|
||||
phone = normalizePhone(phone)
|
||||
firstName = strings.TrimSpace(firstName)
|
||||
lastName = strings.TrimSpace(lastName)
|
||||
if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 {
|
||||
return domain.User{}, domain.Message{}, domain.ErrFirstNameInvalid
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone {
|
||||
return domain.User{}, domain.Message{}, ErrCodeInvalid
|
||||
}
|
||||
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
u, err := s.users.Create(ctx, domain.User{
|
||||
AccessHash: accessHash,
|
||||
Phone: phone,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
if err := s.bind(ctx, auth, u.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
loginMessage, err := s.recordLoginMessage(ctx, u.ID, rec.Code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return u, loginMessage, nil
|
||||
}
|
||||
|
||||
// LogOut 解绑当前 auth_key 的授权。
|
||||
func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error {
|
||||
return s.auths.Delete(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
auth.UserID = userID
|
||||
return s.auths.Bind(ctx, auth)
|
||||
}
|
||||
|
||||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
|
||||
|
||||
This code can be used to log in to your Telegram account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
|
||||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||
if s.messages == nil || s.dialogs == nil {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
body := fmt.Sprintf(loginMessageTpl, code)
|
||||
codeOffset := len("Login code: ")
|
||||
msg, err := s.messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: int(time.Now().Unix()),
|
||||
Body: body,
|
||||
Entities: []domain.MessageEntity{
|
||||
{Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||||
{Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{
|
||||
Peer: msg.Peer,
|
||||
TopMessage: msg.ID,
|
||||
TopMessageDate: msg.Date,
|
||||
UnreadCount: 1,
|
||||
}); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if !found {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if inner.Nonce != binding.Nonce ||
|
||||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
||||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
||||
inner.TempSessionID != sessionID ||
|
||||
inner.ExpiresAt != binding.ExpiresAt {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
return inner, nil
|
||||
}
|
||||
|
||||
func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) {
|
||||
var msg mtcrypto.EncryptedMessage
|
||||
if err := msg.Decode(&bin.Buffer{Buf: encrypted}); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if msg.AuthKeyID != perm.ID || len(msg.EncryptedData) == 0 || len(msg.EncryptedData)%aes.BlockSize != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
key, iv := mtcrypto.KeysV1(mtcrypto.Key(perm.Value), msg.MsgKey)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
plaintext := make([]byte, len(msg.EncryptedData))
|
||||
ige.DecryptBlocks(block, iv[:], plaintext, msg.EncryptedData)
|
||||
|
||||
const headerLen = 16 + 8 + 4 + 4
|
||||
if len(plaintext) < headerLen {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
b := &bin.Buffer{Buf: plaintext}
|
||||
randomPrefix := make([]byte, 16)
|
||||
if err := b.ConsumeN(randomPrefix, len(randomPrefix)); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if _, err := b.Long(); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if _, err := b.Int32(); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
msgLen, err := b.Int32()
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
if msgLen <= 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
bodyEnd := headerLen + int(msgLen)
|
||||
if bodyEnd > len(plaintext) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if msg.MsgKey != mtcrypto.MessageKeyV1(plaintext[:bodyEnd]) {
|
||||
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
body := plaintext[headerLen:bodyEnd]
|
||||
var inner mtcrypto.BindAuthKeyInner
|
||||
if err := inner.Decode(&bin.Buffer{Buf: body}); err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, err
|
||||
}
|
||||
return inner, nil
|
||||
}
|
||||
|
||||
func authKeyIDFromInt64(v int64) [8]byte {
|
||||
var id [8]byte
|
||||
binary.LittleEndian.PutUint64(id[:], uint64(v))
|
||||
return id
|
||||
}
|
||||
|
||||
func authKeyIDInt64(id [8]byte) int64 {
|
||||
return int64(binary.LittleEndian.Uint64(id[:]))
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func randomInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return int64(binary.LittleEndian.Uint64(b[:])), nil
|
||||
}
|
||||
259
internal/app/auth/service_test.go
Normal file
259
internal/app/auth/service_test.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mtcrypto "github.com/gotd/td/crypto"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
permKey := testAuthKey(0x11)
|
||||
tempKey := testAuthKey(0x55)
|
||||
saveAuthKey(t, keys, permKey)
|
||||
saveAuthKey(t, keys, tempKey)
|
||||
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345")
|
||||
|
||||
const (
|
||||
nonce = int64(0x12345678)
|
||||
sessionID = int64(0x1020304050)
|
||||
msgID = int64(0x0102030405060708)
|
||||
)
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
encrypted, err := mtcrypto.EncryptBindMessage(
|
||||
bytes.NewReader(bytes.Repeat([]byte{0xCD}, 128)),
|
||||
permKey,
|
||||
msgID,
|
||||
&mtcrypto.BindAuthKeyInner{
|
||||
Nonce: nonce,
|
||||
TempAuthKeyID: tempKey.IntID(),
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
TempSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt bind message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
ExpiresAt: expiresAt,
|
||||
EncryptedMessage: encrypted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BindTempAuthKey valid message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
ExpiresAt: expiresAt,
|
||||
EncryptedMessage: encrypted,
|
||||
})
|
||||
if !errors.Is(err, ErrEncryptedMessageInvalid) {
|
||||
t.Fatalf("BindTempAuthKey wrong session err = %v, want ErrEncryptedMessageInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
permKey := testAuthKey(0x11)
|
||||
tempKey := testAuthKey(0x55)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAuthKey: %v", err)
|
||||
}
|
||||
if !ok || got != permKey.ID {
|
||||
t.Fatalf("resolved = %x ok=%v, want perm %x", got, ok, permKey.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004310")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
|
||||
_, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "15550004310", hash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignIn with digits-only phone: %v", err)
|
||||
}
|
||||
if !needSignUp {
|
||||
t.Fatal("SignIn needSignUp = false, want true")
|
||||
}
|
||||
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+1 555 000 4310", hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp with formatted phone: %v", err)
|
||||
}
|
||||
if u.Phone != "15550004310" {
|
||||
t.Fatalf("created phone = %q, want normalized digits", u.Phone)
|
||||
}
|
||||
if u.ID != domain.UserIDSequenceBase {
|
||||
t.Fatalf("created user id = %d, want base %d", u.ID, domain.UserIDSequenceBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
var key1, key2 [8]byte
|
||||
key1[0] = 1
|
||||
key2[0] = 2
|
||||
|
||||
hash1, err := svc.SendCode(ctx, "+15550005001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user1: %v", err)
|
||||
}
|
||||
user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key1}, "+15550005001", hash1, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user1: %v", err)
|
||||
}
|
||||
hash2, err := svc.SendCode(ctx, "+15550005002")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user2: %v", err)
|
||||
}
|
||||
user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key2}, "+15550005002", hash2, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user2: %v", err)
|
||||
}
|
||||
|
||||
got1, found, err := svc.UserID(ctx, key1)
|
||||
if err != nil || !found || got1 != user1.ID {
|
||||
t.Fatalf("key1 user = %d found=%v err=%v, want %d", got1, found, err, user1.ID)
|
||||
}
|
||||
got2, found, err := svc.UserID(ctx, key2)
|
||||
if err != nil || !found || got2 != user2.ID {
|
||||
t.Fatalf("key2 user = %d found=%v err=%v, want %d", got2, found, err, user2.ID)
|
||||
}
|
||||
if got1 == got2 {
|
||||
t.Fatalf("auth keys mapped to same user id %d", got1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
var key [8]byte
|
||||
key[0] = 9
|
||||
|
||||
hash1, err := svc.SendCode(ctx, "+15550006001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user1: %v", err)
|
||||
}
|
||||
user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006001", hash1, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user1: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user1.ID {
|
||||
t.Fatalf("initial auth user = %d found=%v err=%v, want %d", got, found, err, user1.ID)
|
||||
}
|
||||
if err := svc.LogOut(ctx, key); err != nil {
|
||||
t.Fatalf("LogOut: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, key); err != nil || found || got != 0 {
|
||||
t.Fatalf("after logout user = %d found=%v err=%v, want none", got, found, err)
|
||||
}
|
||||
|
||||
hash2, err := svc.SendCode(ctx, "+15550006002")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode user2: %v", err)
|
||||
}
|
||||
user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006002", hash2, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp user2: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user2.ID {
|
||||
t.Fatalf("after switch user = %d found=%v err=%v, want %d", got, found, err, user2.ID)
|
||||
}
|
||||
if user1.ID == user2.ID {
|
||||
t.Fatalf("user ids did not change after switch: %d", user1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004311")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
u, msg, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004311", hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID {
|
||||
t.Fatalf("dialogs = %+v, want official system dialog", list.Dialogs)
|
||||
}
|
||||
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support {
|
||||
t.Fatalf("users = %+v, want verified support system user", list.Users)
|
||||
}
|
||||
if msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") {
|
||||
t.Fatalf("login message = %+v, want returned official login code message", msg)
|
||||
}
|
||||
if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Login code: 12345") {
|
||||
t.Fatalf("messages = %+v, want login code message", list.Messages)
|
||||
}
|
||||
if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 1 {
|
||||
t.Fatalf("dialog top/unread = %+v, message = %+v", list.Dialogs[0], list.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func testAuthKey(seed byte) mtcrypto.AuthKey {
|
||||
var raw mtcrypto.Key
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return raw.WithID()
|
||||
}
|
||||
|
||||
func saveAuthKey(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey) {
|
||||
t.Helper()
|
||||
var value [256]byte
|
||||
copy(value[:], key.Value[:])
|
||||
if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
}
|
||||
2
internal/app/channels/doc.go
Normal file
2
internal/app/channels/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package channels contains domain service orchestration for Telegram channels and supergroups.
|
||||
package channels
|
||||
1514
internal/app/channels/service.go
Normal file
1514
internal/app/channels/service.go
Normal file
File diff suppressed because it is too large
Load diff
1784
internal/app/channels/service_test.go
Normal file
1784
internal/app/channels/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
3
internal/app/contacts/doc.go
Normal file
3
internal/app/contacts/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package contacts 是联系人应用服务:联系人、拉黑、搜索。
|
||||
// 第一阶段实现 PG-backed 空账号通讯录查询;联系人导入留后续业务迭代。
|
||||
package contacts
|
||||
266
internal/app/contacts/service.go
Normal file
266
internal/app/contacts/service.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrContactIDInvalid = errors.New("contact id invalid")
|
||||
ErrContactNameEmpty = errors.New("contact name empty")
|
||||
)
|
||||
|
||||
const maxSearchLimit = 50
|
||||
|
||||
// Service 提供通讯录查询。
|
||||
type Service struct {
|
||||
contacts store.ContactStore
|
||||
users store.UserStore
|
||||
}
|
||||
|
||||
// NewService 创建 contacts 服务。
|
||||
func NewService(contacts store.ContactStore, users ...store.UserStore) *Service {
|
||||
s := &Service{contacts: contacts}
|
||||
if len(users) > 0 {
|
||||
s.users = users[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 {
|
||||
return domain.ContactList{}, false, nil
|
||||
}
|
||||
list, err := s.contacts.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.ContactList{}, false, err
|
||||
}
|
||||
if s.users != nil && len(list.Contacts) > 0 {
|
||||
if err := s.attachCurrentLastSeen(ctx, &list); err != nil {
|
||||
return domain.ContactList{}, false, err
|
||||
}
|
||||
}
|
||||
if hash != 0 && hash == list.Hash {
|
||||
return list, true, nil
|
||||
}
|
||||
return list, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachCurrentLastSeen(ctx context.Context, list *domain.ContactList) error {
|
||||
ids := make([]int64, 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{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
users, err := s.users.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := make(map[int64]domain.User, len(users))
|
||||
for _, u := range users {
|
||||
current[u.ID] = u
|
||||
}
|
||||
for i := range list.Contacts {
|
||||
if u, ok := current[list.Contacts[i].User.ID]; ok {
|
||||
list.Contacts[i].User.LastSeenAt = u.LastSeenAt
|
||||
list.Contacts[i].User.Status = u.Status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.FirstName == "" && input.LastName == "" {
|
||||
return domain.Contact{}, ErrContactNameEmpty
|
||||
}
|
||||
if s.users != nil {
|
||||
target, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.Phone == "" {
|
||||
input.Phone = target.Phone
|
||||
}
|
||||
}
|
||||
contact, err := s.contacts.Upsert(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) {
|
||||
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || len(inputs) == 0 {
|
||||
return domain.ImportContactsResult{}, nil
|
||||
}
|
||||
out := domain.ImportContactsResult{
|
||||
Imported: make([]domain.ImportedContact, 0, len(inputs)),
|
||||
Contacts: make([]domain.Contact, 0, len(inputs)),
|
||||
}
|
||||
normalized := make([]domain.ContactInput, 0, len(inputs))
|
||||
phones := make([]string, 0, len(inputs))
|
||||
seenPhones := make(map[string]struct{}, len(inputs))
|
||||
for _, input := range inputs {
|
||||
phone := normalizePhone(input.Phone)
|
||||
if phone == "" {
|
||||
continue
|
||||
}
|
||||
input.Phone = phone
|
||||
normalized = append(normalized, input)
|
||||
if _, ok := seenPhones[phone]; ok {
|
||||
continue
|
||||
}
|
||||
seenPhones[phone] = struct{}{}
|
||||
phones = append(phones, phone)
|
||||
}
|
||||
if len(phones) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
targets, err := s.users.ByPhones(ctx, phones)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
byPhone := make(map[string]domain.User, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Phone != "" {
|
||||
byPhone[target.Phone] = target
|
||||
}
|
||||
}
|
||||
upsertsByTarget := make(map[int64]domain.ContactInput, len(targets))
|
||||
order := make([]int64, 0, len(targets))
|
||||
seenTargets := map[int64]struct{}{}
|
||||
for _, input := range normalized {
|
||||
target, found := byPhone[input.Phone]
|
||||
if !found || target.ID == userID || target.ID == 0 {
|
||||
continue
|
||||
}
|
||||
input.ContactUserID = target.ID
|
||||
if input.FirstName == "" && input.LastName == "" {
|
||||
input.FirstName = target.FirstName
|
||||
input.LastName = target.LastName
|
||||
}
|
||||
if _, ok := seenTargets[target.ID]; !ok {
|
||||
seenTargets[target.ID] = struct{}{}
|
||||
order = append(order, target.ID)
|
||||
}
|
||||
out.Imported = append(out.Imported, domain.ImportedContact{UserID: target.ID, ClientID: input.ClientID})
|
||||
upsertsByTarget[target.ID] = input
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
upserts := make([]domain.ContactInput, 0, len(order))
|
||||
for _, targetID := range order {
|
||||
upserts = append(upserts, upsertsByTarget[targetID])
|
||||
}
|
||||
contacts, err := s.contacts.UpsertMany(ctx, userID, upserts)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
out.Contacts = append(out.Contacts, contacts...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) {
|
||||
if s == nil || s.users == nil || userID == 0 {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
query = strings.TrimPrefix(query, "@")
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > maxSearchLimit {
|
||||
limit = maxSearchLimit
|
||||
}
|
||||
return s.users.Search(ctx, userID, query, normalizePhone(query), limit)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.contacts.Delete(ctx, userID, contactUserIDs)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
contact, found, err := s.contacts.UpdateNote(ctx, userID, contactUserID, note, entities)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
|
||||
return domain.PeerSettings{}, nil
|
||||
}
|
||||
_, found, err := s.contacts.Get(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.PeerSettings{}, err
|
||||
}
|
||||
return domain.PeerSettings{
|
||||
AddContact: !found,
|
||||
BlockContact: !found,
|
||||
ShareContact: found,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
|
||||
list, notModified, err := s.GetContacts(ctx, userID, hash)
|
||||
if err != nil || notModified {
|
||||
return nil, notModified, err
|
||||
}
|
||||
ids := make([]int, 0, len(list.Contacts))
|
||||
for _, contact := range list.Contacts {
|
||||
ids = append(ids, int(contact.User.ID))
|
||||
}
|
||||
return ids, false, nil
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
if !utf8.ValidString(phone) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
44
internal/app/contacts/service_test.go
Normal file
44
internal/app/contacts/service_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users)
|
||||
|
||||
res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{
|
||||
{ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"},
|
||||
{ClientID: 12, Phone: "15551234567", FirstName: "Alice Final"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts: %v", err)
|
||||
}
|
||||
if len(res.Imported) != 2 {
|
||||
t.Fatalf("imported = %d, want 2", len(res.Imported))
|
||||
}
|
||||
if res.Imported[0].UserID != target.ID || res.Imported[1].UserID != target.ID {
|
||||
t.Fatalf("imported user ids = %+v, want target %d", res.Imported, target.ID)
|
||||
}
|
||||
if len(res.Contacts) != 1 {
|
||||
t.Fatalf("contacts = %d, want 1 deduped upsert", len(res.Contacts))
|
||||
}
|
||||
if res.Contacts[0].FirstName != "Alice Final" {
|
||||
t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName)
|
||||
}
|
||||
}
|
||||
3
internal/app/dialogs/doc.go
Normal file
3
internal/app/dialogs/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package dialogs 是会话应用服务:会话列表、未读数、置顶、草稿。
|
||||
// 第一阶段实现 PG-backed 空账号会话摘要查询;真实消息闭环留第二阶段。
|
||||
package dialogs
|
||||
593
internal/app/dialogs/service.go
Normal file
593
internal/app/dialogs/service.go
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供会话列表查询。
|
||||
type Service struct {
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
}
|
||||
|
||||
// NewService 创建 dialogs 服务。
|
||||
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
|
||||
s := &Service{dialogs: dialogs}
|
||||
if len(channels) > 0 {
|
||||
s.channels = channels[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
if filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID && filter.Folder == nil {
|
||||
if s.dialogs == nil {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, filter.FolderID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
filter.Folder = &folder
|
||||
}
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if s.channels != nil {
|
||||
list, err := s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, list)
|
||||
}
|
||||
sortDialogList(out.Dialogs)
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if len(out.Dialogs) > limit {
|
||||
keep := make(map[domain.Peer]struct{}, limit)
|
||||
for _, d := range out.Dialogs[:limit] {
|
||||
keep[d.Peer] = struct{}{}
|
||||
}
|
||||
out.Dialogs = out.Dialogs[:limit]
|
||||
out.Messages = filterPrivateMessagesByPeer(out.Messages, keep)
|
||||
out.ChannelMessages = filterChannelMessagesByPeer(out.ChannelMessages, keep)
|
||||
out.Channels = filterChannelsByPeer(out.Channels, keep)
|
||||
}
|
||||
if out.Count == 0 {
|
||||
out.Count = len(out.Dialogs)
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
|
||||
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
if len(peers) > domain.MaxDialogFolderPeers {
|
||||
return domain.DialogList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
userPeers := make([]domain.Peer, 0, len(peers))
|
||||
channelIDs := make([]int64, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userPeers = append(userPeers, peer)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, peer.ID)
|
||||
}
|
||||
}
|
||||
var out domain.DialogList
|
||||
if len(userPeers) > 0 && s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByPeers(ctx, userID, userPeers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if len(channelIDs) > 0 && s.channels != nil {
|
||||
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, list)
|
||||
out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID int64, channelIDs []int64, out domain.DialogList) (domain.DialogList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
present := make(map[int64]struct{}, len(out.Dialogs))
|
||||
for _, dialog := range out.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 {
|
||||
present[dialog.Peer.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
if _, ok := present[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
view, err := s.channels.GetChannel(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
if isChannelPreviewAccessError(err) {
|
||||
continue
|
||||
}
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
if isChannelPreviewAccessError(err) {
|
||||
continue
|
||||
}
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
||||
dialog := dialogFromChannelView(view)
|
||||
if len(history.Messages) > 0 {
|
||||
top := history.Messages[0]
|
||||
dialog.TopMessage = top.ID
|
||||
dialog.TopMessageDate = top.Date
|
||||
out.ChannelMessages = append(out.ChannelMessages, top)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
out.Channels = append(out.Channels, view.Channel)
|
||||
out.Channels = append(out.Channels, history.Channels...)
|
||||
out.Users = append(out.Users, history.Users...)
|
||||
out.Count++
|
||||
present[channelID] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isChannelPreviewAccessError(err error) bool {
|
||||
return errors.Is(err, domain.ErrChannelPrivate) ||
|
||||
errors.Is(err, domain.ErrChannelUserBanned) ||
|
||||
errors.Is(err, domain.ErrChannelInvalid)
|
||||
}
|
||||
|
||||
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
|
||||
dialog := view.Dialog
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveDraft stores or clears a cloud draft for one peer/topic.
|
||||
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateDraft(draft); err != nil {
|
||||
return err
|
||||
}
|
||||
if draft.Empty() {
|
||||
_, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
|
||||
return err
|
||||
}
|
||||
return s.dialogs.SaveDraft(ctx, userID, draft)
|
||||
}
|
||||
|
||||
// DeleteDraft clears one cloud draft.
|
||||
func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateDraftKey(peer, topMessageID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
|
||||
}
|
||||
|
||||
// ListDrafts returns bounded cloud drafts for messages.getAllDrafts.
|
||||
func (s *Service) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.dialogs.ListDrafts(ctx, userID, clampDraftLimit(limit))
|
||||
}
|
||||
|
||||
// ClearDrafts deletes bounded cloud drafts for messages.clearAllDrafts.
|
||||
func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
|
||||
}
|
||||
|
||||
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
|
||||
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
|
||||
default:
|
||||
if s.dialogs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetPinned(ctx, userID, peer, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
|
||||
if s == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.channels != nil {
|
||||
if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
|
||||
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
|
||||
default:
|
||||
if s.dialogs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var out []domain.Peer
|
||||
if s.dialogs != nil {
|
||||
peers, err := s.dialogs.ListUnreadMarked(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, peers...)
|
||||
}
|
||||
if s.channels != nil {
|
||||
peers, err := s.channels.ListChannelUnreadMarked(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, peers...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.PeerSettingsBarHidden(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return domain.DialogFolderList{}, nil
|
||||
}
|
||||
return s.dialogs.ListFolders(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.UpsertFolder(ctx, userID, folder)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.DeleteFolder(ctx, userID, folderID)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.ReorderFolders(ctx, userID, order)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.SetFolderTagsEnabled(ctx, userID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
privatePeers := make([]domain.FolderPeerUpdate, 0, len(peers))
|
||||
channelPeers := make([]domain.FolderPeerUpdate, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Peer.Type == domain.PeerTypeChannel {
|
||||
channelPeers = append(channelPeers, peer)
|
||||
} else {
|
||||
privatePeers = append(privatePeers, peer)
|
||||
}
|
||||
}
|
||||
if len(privatePeers) > 0 && s.dialogs != nil {
|
||||
if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(channelPeers) > 0 && s.channels != nil {
|
||||
if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.DialogList) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
if len(byPeer) == 0 {
|
||||
return nil
|
||||
}
|
||||
attached := false
|
||||
for i := range list.Dialogs {
|
||||
draft, ok := byPeer[list.Dialogs[i].Peer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := cloneDraft(draft)
|
||||
list.Dialogs[i].Draft = &d
|
||||
attached = true
|
||||
}
|
||||
if attached {
|
||||
list.Hash = dialogHashWithDrafts(list.Hash, list.Dialogs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraft(draft domain.DialogDraft) error {
|
||||
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(draft.Entities) > domain.MaxMessageEntityCount {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(draft.Message) > domain.MaxMessageTextLength {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if domain.ValidateMessageReplyBounds(draft.ReplyTo) != nil {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraftKey(peer domain.Peer, topMessageID int) error {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampDraftLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
return domain.MaxDialogDraftsPerUser
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
|
||||
draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...)
|
||||
if draft.ReplyTo != nil {
|
||||
reply := *draft.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), draft.ReplyTo.QuoteEntities...)
|
||||
draft.ReplyTo = &reply
|
||||
}
|
||||
if draft.WebPage != nil {
|
||||
webpage := *draft.WebPage
|
||||
draft.WebPage = &webpage
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
|
||||
if len(dialogs) == 0 {
|
||||
return base
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [48]byte
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(base))
|
||||
_, _ = h.Write(buf[:8])
|
||||
for _, d := range dialogs {
|
||||
if d.Draft == nil {
|
||||
continue
|
||||
}
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.Draft.TopMessageID))
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.Draft.Date))
|
||||
binary.LittleEndian.PutUint64(buf[16:24], uint64(len(d.Draft.Message)))
|
||||
if d.Draft.NoWebpage {
|
||||
buf[24] = 1
|
||||
} else {
|
||||
buf[24] = 0
|
||||
}
|
||||
if d.Draft.InvertMedia {
|
||||
buf[25] = 1
|
||||
} else {
|
||||
buf[25] = 0
|
||||
}
|
||||
binary.LittleEndian.PutUint64(buf[26:34], uint64(len(d.Draft.Entities)))
|
||||
binary.LittleEndian.PutUint64(buf[34:42], uint64(d.Draft.Effect))
|
||||
_, _ = h.Write(buf[:])
|
||||
_, _ = h.Write([]byte(d.Draft.Message))
|
||||
if d.Draft.WebPage != nil {
|
||||
_, _ = h.Write([]byte(d.Draft.WebPage.URL))
|
||||
}
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.Messages = append(out.Messages, in.Messages...)
|
||||
out.Users = append(out.Users, in.Users...)
|
||||
out.Count += in.Count
|
||||
out.Hash ^= in.Hash
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeChannelDialogs(out domain.DialogList, in domain.ChannelDialogList) domain.DialogList {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.ChannelMessages = append(out.ChannelMessages, in.Messages...)
|
||||
out.Channels = append(out.Channels, in.Channels...)
|
||||
out.Users = append(out.Users, in.Users...)
|
||||
out.Count += in.Count
|
||||
out.Hash ^= in.Hash
|
||||
return out
|
||||
}
|
||||
|
||||
func sortDialogList(dialogs []domain.Dialog) {
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
if dialogs[i].Pinned != dialogs[j].Pinned {
|
||||
return dialogs[i].Pinned
|
||||
}
|
||||
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
|
||||
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
|
||||
}
|
||||
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
|
||||
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
|
||||
}
|
||||
if dialogs[i].TopMessage != dialogs[j].TopMessage {
|
||||
return dialogs[i].TopMessage > dialogs[j].TopMessage
|
||||
}
|
||||
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
|
||||
})
|
||||
}
|
||||
|
||||
func filterPrivateMessagesByPeer(messages []domain.Message, keep map[domain.Peer]struct{}) []domain.Message {
|
||||
out := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if _, ok := keep[msg.Peer]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterChannelMessagesByPeer(messages []domain.ChannelMessage, keep map[domain.Peer]struct{}) []domain.ChannelMessage {
|
||||
out := messages[:0]
|
||||
for _, msg := range messages {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}
|
||||
if _, ok := keep[peer]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterChannelsByPeer(channels []domain.Channel, keep map[domain.Peer]struct{}) []domain.Channel {
|
||||
out := channels[:0]
|
||||
for _, ch := range channels {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}
|
||||
if _, ok := keep[peer]; ok {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
282
internal/app/dialogs/service_test.go
Normal file
282
internal/app/dialogs/service_test.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
created, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Offline Read",
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 42,
|
||||
Message: "restore read outbox",
|
||||
Date: 11,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if _, err := channels.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
MaxID: sent.Message.ID,
|
||||
Date: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReadHistory: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
dialog := findChannelDialog(t, list, created.Channel.ID)
|
||||
if dialog.ReadOutboxMaxID != sent.Message.ID {
|
||||
t.Fatalf("getDialogs read_outbox = %d, want %d", dialog.ReadOutboxMaxID, sent.Message.ID)
|
||||
}
|
||||
|
||||
peerList, err := dialogs.GetPeerDialogs(ctx, 1001, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs: %v", err)
|
||||
}
|
||||
peerDialog := findChannelDialog(t, peerList, created.Channel.ID)
|
||||
if peerDialog.ReadOutboxMaxID != sent.Message.ID {
|
||||
t.Fatalf("getPeerDialogs read_outbox = %d, want %d", peerDialog.ReadOutboxMaxID, sent.Message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
first, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Pinned One",
|
||||
Date: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat first: %v", err)
|
||||
}
|
||||
second, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Pinned Two",
|
||||
Date: 21,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat second: %v", err)
|
||||
}
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}
|
||||
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned second = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.ReorderPinned(ctx, 1001, []domain.Peer{secondPeer, firstPeer}, true); err != nil {
|
||||
t.Fatalf("ReorderPinned: %v", err)
|
||||
}
|
||||
if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.EditPeerFolders(ctx, 1001, []domain.FolderPeerUpdate{
|
||||
{Peer: firstPeer, FolderID: domain.DialogArchiveFolderID},
|
||||
}); err != nil {
|
||||
t.Fatalf("EditPeerFolders: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
firstDialog := findChannelDialog(t, list, first.Channel.ID)
|
||||
if !firstDialog.Pinned || firstDialog.PinnedOrder != 1 || !firstDialog.UnreadMark || firstDialog.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("first dialog = %+v, want pinned order 1, unread mark, archived", firstDialog)
|
||||
}
|
||||
secondDialog := findChannelDialog(t, list, second.Channel.ID)
|
||||
if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 {
|
||||
t.Fatalf("second dialog = %+v, want pinned order 2", secondDialog)
|
||||
}
|
||||
marks, err := dialogs.UnreadMarks(ctx, 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("UnreadMarks: %v", err)
|
||||
}
|
||||
if len(marks) != 1 || marks[0] != firstPeer {
|
||||
t.Fatalf("unread marks = %+v, want first channel", marks)
|
||||
}
|
||||
archived, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs archive: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("archived dialog = %+v, want archive folder", got)
|
||||
}
|
||||
|
||||
custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogCustomFolderMinID,
|
||||
Folder: &domain.DialogFolder{ID: domain.DialogCustomFolderMinID, Groups: true},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs custom groups: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, custom, first.Channel.ID); got.Peer.ID != first.Channel.ID {
|
||||
t.Fatalf("custom group dialog = %+v, want first channel", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
old, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Older Channel",
|
||||
Date: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat old: %v", err)
|
||||
}
|
||||
newer, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Newer Channel",
|
||||
Date: 30,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat newer: %v", err)
|
||||
}
|
||||
|
||||
first, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs first: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID {
|
||||
t.Fatalf("first page dialogs = %+v, want newer channel", first.Dialogs)
|
||||
}
|
||||
|
||||
next, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate,
|
||||
OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true,
|
||||
OffsetPeer: first.Dialogs[0].Peer,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs next: %v", err)
|
||||
}
|
||||
if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != old.Channel.ID {
|
||||
t.Fatalf("next page dialogs = %+v, want only older channel", next.Dialogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsRejectsHugeVector(t *testing.T) {
|
||||
dialogs := NewService(nil, memory.NewChannelStore())
|
||||
peers := make([]domain.Peer, domain.MaxDialogFolderPeers+1)
|
||||
for i := range peers {
|
||||
peers[i] = domain.Peer{Type: domain.PeerTypeChannel, ID: int64(i + 1)}
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(context.Background(), 1001, peers); !errors.Is(err, domain.ErrChannelInvalid) {
|
||||
t.Fatalf("GetPeerDialogs huge vector err = %v, want ErrChannelInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
public, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Public Peer Dialog",
|
||||
Broadcast: true,
|
||||
Date: 1700002000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel public: %v", err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
UserID: 1001,
|
||||
ChannelID: public.Channel.ID,
|
||||
Username: "public_peer_dialog",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateUsername public: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: public.Channel.ID,
|
||||
RandomID: 99,
|
||||
Message: "public peer dialog top",
|
||||
Date: 1700002010,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage public: %v", err)
|
||||
}
|
||||
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Private Peer Dialog",
|
||||
Broadcast: true,
|
||||
Date: 1700002020,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel private: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: public.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs public preview: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 {
|
||||
t.Fatalf("dialogs = %+v, want only public preview dialog", list.Dialogs)
|
||||
}
|
||||
dialog := findChannelDialog(t, list, public.Channel.ID)
|
||||
if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date {
|
||||
t.Fatalf("preview dialog top = id %d date %d, want %d/%d", dialog.TopMessage, dialog.TopMessageDate, sent.Message.ID, sent.Message.Date)
|
||||
}
|
||||
if !dialog.ChannelLeft {
|
||||
t.Fatalf("preview dialog ChannelLeft = false, want read-only left preview")
|
||||
}
|
||||
if dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < sent.Message.ID || dialog.ReadOutboxMaxID < sent.Message.ID {
|
||||
t.Fatalf("preview dialog read/unread = %+v, want read through top and no unread", dialog)
|
||||
}
|
||||
if len(list.ChannelMessages) != 1 || list.ChannelMessages[0].Body != "public peer dialog top" {
|
||||
t.Fatalf("channel messages = %+v, want public top message", list.ChannelMessages)
|
||||
}
|
||||
if len(list.Channels) != 1 || list.Channels[0].ID != public.Channel.ID {
|
||||
t.Fatalf("channels = %+v, want public channel shell", list.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog {
|
||||
t.Helper()
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == channelID {
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs)
|
||||
return domain.Dialog{}
|
||||
}
|
||||
236
internal/app/files/blobcache.go
Normal file
236
internal/app/files/blobcache.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile
|
||||
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
|
||||
// reaction/头像更被大量用户重复拉)。
|
||||
//
|
||||
// FileBlob 元数据小(约百字节)且内容不可变:location_key 一旦写入即固定指向同一 object_key,
|
||||
// 新建 blob 用随机 id 生成 location_key 不会与已缓存项冲突,故只读填充、无需失效。
|
||||
type blobMetaCache struct {
|
||||
mu sync.Mutex
|
||||
cap int
|
||||
ll *list.List
|
||||
m map[string]*list.Element
|
||||
}
|
||||
|
||||
type blobMetaEntry struct {
|
||||
key string
|
||||
blob domain.FileBlob
|
||||
}
|
||||
|
||||
func newBlobMetaCache(capacity int) *blobMetaCache {
|
||||
if capacity <= 0 {
|
||||
capacity = 1
|
||||
}
|
||||
return &blobMetaCache{
|
||||
cap: capacity,
|
||||
ll: list.New(),
|
||||
m: make(map[string]*list.Element, capacity),
|
||||
}
|
||||
}
|
||||
|
||||
// get 返回缓存的 FileBlob 并把其移到 LRU 头部;未命中返回 ok=false。
|
||||
func (c *blobMetaCache) get(key string) (domain.FileBlob, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
return el.Value.(*blobMetaEntry).blob, true
|
||||
}
|
||||
return domain.FileBlob{}, false
|
||||
}
|
||||
|
||||
// put 写入/更新缓存,超出容量时淘汰最久未用项。
|
||||
func (c *blobMetaCache) put(key string, blob domain.FileBlob) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
el.Value.(*blobMetaEntry).blob = blob
|
||||
c.ll.MoveToFront(el)
|
||||
return
|
||||
}
|
||||
c.m[key] = c.ll.PushFront(&blobMetaEntry{key: key, blob: blob})
|
||||
if c.ll.Len() > c.cap {
|
||||
if oldest := c.ll.Back(); oldest != nil {
|
||||
c.ll.Remove(oldest)
|
||||
delete(c.m, oldest.Value.(*blobMetaEntry).key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blobBytesCache 是 object_key → 小 blob 全量字节的 LRU。Sticker / reaction /
|
||||
// 缩略图通常只有几 KB 到几十 KB,缓存全量内容可以避开点击历史时的本地磁盘冷读抖动;
|
||||
// 大媒体仍由 BlobBackend.GetRange 分段读取,避免把大文件放进内存。
|
||||
type blobBytesCache struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int
|
||||
used int
|
||||
ll *list.List
|
||||
m map[string]*list.Element
|
||||
}
|
||||
|
||||
type blobBytesEntry struct {
|
||||
key string
|
||||
bytes []byte
|
||||
size int
|
||||
}
|
||||
|
||||
func newBlobBytesCache(maxBytes int) *blobBytesCache {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 1
|
||||
}
|
||||
return &blobBytesCache{
|
||||
maxBytes: maxBytes,
|
||||
ll: list.New(),
|
||||
m: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *blobBytesCache) get(key string) ([]byte, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
entry := el.Value.(*blobBytesEntry)
|
||||
return append([]byte(nil), entry.bytes...), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *blobBytesCache) has(key string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *blobBytesCache) put(key string, bytes []byte) {
|
||||
if len(bytes) > c.maxBytes {
|
||||
return
|
||||
}
|
||||
copied := append([]byte(nil), bytes...)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.m[key]; ok {
|
||||
entry := el.Value.(*blobBytesEntry)
|
||||
c.used += len(copied) - entry.size
|
||||
entry.bytes = copied
|
||||
entry.size = len(copied)
|
||||
c.ll.MoveToFront(el)
|
||||
} else {
|
||||
entry := &blobBytesEntry{key: key, bytes: copied, size: len(copied)}
|
||||
c.m[key] = c.ll.PushFront(entry)
|
||||
c.used += entry.size
|
||||
}
|
||||
for c.used > c.maxBytes {
|
||||
oldest := c.ll.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
c.ll.Remove(oldest)
|
||||
entry := oldest.Value.(*blobBytesEntry)
|
||||
delete(c.m, entry.key)
|
||||
c.used -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
type stickerSetFullCache struct {
|
||||
mu sync.RWMutex
|
||||
byID map[int64]stickerSetFullEntry
|
||||
byShort map[string]int64
|
||||
bySystem map[string]int64
|
||||
}
|
||||
|
||||
type stickerSetFullEntry struct {
|
||||
set domain.StickerSet
|
||||
docs []domain.Document
|
||||
}
|
||||
|
||||
func newStickerSetFullCache() *stickerSetFullCache {
|
||||
return &stickerSetFullCache{
|
||||
byID: map[int64]stickerSetFullEntry{},
|
||||
byShort: map[string]int64{},
|
||||
bySystem: map[string]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stickerSetFullCache) get(ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
var id int64
|
||||
switch ref.Kind {
|
||||
case domain.StickerSetRefByID:
|
||||
id = ref.ID
|
||||
case domain.StickerSetRefByShortName:
|
||||
id = c.byShort[ref.ShortName]
|
||||
case domain.StickerSetRefBySystem:
|
||||
id = c.bySystem[ref.SystemKey]
|
||||
default:
|
||||
return domain.StickerSet{}, nil, false
|
||||
}
|
||||
entry, ok := c.byID[id]
|
||||
if !ok {
|
||||
return domain.StickerSet{}, nil, false
|
||||
}
|
||||
return copyStickerSet(entry.set), copyDocuments(entry.docs), true
|
||||
}
|
||||
|
||||
func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document) {
|
||||
if set.ID == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.byID[set.ID] = stickerSetFullEntry{
|
||||
set: copyStickerSet(set),
|
||||
docs: copyDocuments(docs),
|
||||
}
|
||||
if set.ShortName != "" {
|
||||
c.byShort[set.ShortName] = set.ID
|
||||
}
|
||||
if set.SystemKey != "" {
|
||||
c.bySystem[set.SystemKey] = set.ID
|
||||
}
|
||||
}
|
||||
|
||||
func copyStickerSet(set domain.StickerSet) domain.StickerSet {
|
||||
set.DocumentIDs = append([]int64(nil), set.DocumentIDs...)
|
||||
set.Packs = append([]domain.StickerPack(nil), set.Packs...)
|
||||
for i := range set.Packs {
|
||||
set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...)
|
||||
}
|
||||
set.Thumbs = copyPhotoSizes(set.Thumbs)
|
||||
return set
|
||||
}
|
||||
|
||||
func copyDocuments(docs []domain.Document) []domain.Document {
|
||||
out := append([]domain.Document(nil), docs...)
|
||||
for i := range out {
|
||||
out[i].FileReference = append([]byte(nil), out[i].FileReference...)
|
||||
out[i].Attributes = append([]domain.DocumentAttribute(nil), out[i].Attributes...)
|
||||
for j := range out[i].Attributes {
|
||||
out[i].Attributes[j].Waveform = append([]byte(nil), out[i].Attributes[j].Waveform...)
|
||||
}
|
||||
out[i].Thumbs = copyPhotoSizes(out[i].Thumbs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyPhotoSizes(sizes []domain.PhotoSize) []domain.PhotoSize {
|
||||
out := append([]domain.PhotoSize(nil), sizes...)
|
||||
for i := range out {
|
||||
out[i].Bytes = append([]byte(nil), out[i].Bytes...)
|
||||
out[i].Sizes = append([]int(nil), out[i].Sizes...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
233
internal/app/files/blobcache_test.go
Normal file
233
internal/app/files/blobcache_test.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBlobMetaCacheGetPutEvict(t *testing.T) {
|
||||
c := newBlobMetaCache(2)
|
||||
c.put("a", domain.FileBlob{LocationKey: "a", ObjectKey: "oa"})
|
||||
c.put("b", domain.FileBlob{LocationKey: "b", ObjectKey: "ob"})
|
||||
if b, ok := c.get("a"); !ok || b.ObjectKey != "oa" {
|
||||
t.Fatalf("get a = %+v ok=%v", b, ok)
|
||||
}
|
||||
// 容量 2:刚 access 过 a,再 put c 应淘汰最久未用的 b。
|
||||
c.put("c", domain.FileBlob{LocationKey: "c", ObjectKey: "oc"})
|
||||
if _, ok := c.get("b"); ok {
|
||||
t.Error("b should be evicted (least recently used)")
|
||||
}
|
||||
if _, ok := c.get("a"); !ok {
|
||||
t.Error("a should remain (recently used)")
|
||||
}
|
||||
if _, ok := c.get("c"); !ok {
|
||||
t.Error("c should be present")
|
||||
}
|
||||
}
|
||||
|
||||
// countingMediaStore 统计 GetFileBlob 次数,验证元数据缓存命中后不再查 PG。
|
||||
type countingMediaStore struct {
|
||||
*fakeMediaStore
|
||||
getBlobCalls int
|
||||
getSetByIDCalls int
|
||||
}
|
||||
|
||||
func (c *countingMediaStore) GetFileBlob(ctx context.Context, key string) (domain.FileBlob, bool, error) {
|
||||
c.getBlobCalls++
|
||||
return c.fakeMediaStore.GetFileBlob(ctx, key)
|
||||
}
|
||||
|
||||
func (c *countingMediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) {
|
||||
c.getSetByIDCalls++
|
||||
return c.fakeMediaStore.GetStickerSetByID(ctx, id)
|
||||
}
|
||||
|
||||
type countingBlobBackend struct {
|
||||
BlobBackend
|
||||
getRangeCalls int
|
||||
}
|
||||
|
||||
func (c *countingBlobBackend) GetRange(ctx context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) {
|
||||
c.getRangeCalls++
|
||||
return c.BlobBackend.GetRange(ctx, objectKey, offset, limit)
|
||||
}
|
||||
|
||||
func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
objectKey, err := local.Put(ctx, []byte("0123456789"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
counting := &countingMediaStore{fakeMediaStore: media}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(counting, blobs, 2)
|
||||
|
||||
// 第一次:查 PG 一次并填充元数据缓存;小 blob 读整块进字节缓存后返回 [0,5)。
|
||||
c1, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 0, Limit: 5})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile1 ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(c1.Bytes) != "01234" {
|
||||
t.Errorf("chunk1 = %q, want 01234", c1.Bytes)
|
||||
}
|
||||
if c1.Total != 10 {
|
||||
t.Errorf("total = %d, want 10", c1.Total)
|
||||
}
|
||||
if counting.getBlobCalls != 1 {
|
||||
t.Errorf("getBlobCalls = %d, want 1", counting.getBlobCalls)
|
||||
}
|
||||
if blobs.getRangeCalls != 1 {
|
||||
t.Errorf("getRangeCalls = %d, want 1", blobs.getRangeCalls)
|
||||
}
|
||||
|
||||
// 第二次:同 location 命中元数据与字节缓存;[5,10) 直接从内存切片。
|
||||
c2, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 5, Limit: 5})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile2 ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(c2.Bytes) != "56789" {
|
||||
t.Errorf("chunk2 = %q, want 56789", c2.Bytes)
|
||||
}
|
||||
if counting.getBlobCalls != 1 {
|
||||
t.Errorf("getBlobCalls = %d, want 1 (cache hit)", counting.getBlobCalls)
|
||||
}
|
||||
if blobs.getRangeCalls != 1 {
|
||||
t.Errorf("getRangeCalls = %d, want 1 (byte cache hit)", blobs.getRangeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
content := bytes.Repeat([]byte("x"), blobBytesCacheMaxEntryBytes+2)
|
||||
objectKey, err := local.Put(ctx, content)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: "doc:large",
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(content)),
|
||||
MimeType: "application/octet-stream",
|
||||
}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(media, blobs, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:large", Offset: 1, Limit: 7})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile %d ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
if string(chunk.Bytes) != "xxxxxxx" {
|
||||
t.Fatalf("chunk %d = %q, want seven x bytes", i, chunk.Bytes)
|
||||
}
|
||||
}
|
||||
if blobs.getRangeCalls != 2 {
|
||||
t.Errorf("getRangeCalls = %d, want 2 (large blob is not byte cached)", blobs.getRangeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
mainKey, err := local.Put(ctx, []byte("sticker"))
|
||||
if err != nil {
|
||||
t.Fatalf("put main: %v", err)
|
||||
}
|
||||
thumbKey, err := local.Put(ctx, []byte("thumb"))
|
||||
if err != nil {
|
||||
t.Fatalf("put thumb: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
doc := domain.Document{
|
||||
ID: 100,
|
||||
AccessHash: 1,
|
||||
DCID: 2,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Size: 7,
|
||||
Thumbs: []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 5},
|
||||
},
|
||||
}
|
||||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil {
|
||||
t.Fatalf("put main blob: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil {
|
||||
t.Fatalf("put thumb blob: %v", err)
|
||||
}
|
||||
set := domain.StickerSet{
|
||||
ID: 200,
|
||||
AccessHash: 2,
|
||||
ShortName: "pack",
|
||||
Title: "Pack",
|
||||
Kind: domain.StickerSetKindStickers,
|
||||
Count: 1,
|
||||
DocumentIDs: []int64{
|
||||
doc.ID,
|
||||
},
|
||||
}
|
||||
if err := media.PutStickerSet(ctx, set); err != nil {
|
||||
t.Fatalf("put set: %v", err)
|
||||
}
|
||||
counting := &countingMediaStore{fakeMediaStore: media}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(counting, blobs, 2)
|
||||
|
||||
stats, err := svc.WarmCaches(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("warm caches: %v", err)
|
||||
}
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 {
|
||||
t.Fatalf("warm stats = %+v, want 1 set, 1 doc, 2 blobs", stats)
|
||||
}
|
||||
blobs.getRangeCalls = 0
|
||||
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:100", Offset: 0, Limit: 7})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("getfile ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(chunk.Bytes) != "sticker" {
|
||||
t.Fatalf("chunk = %q, want sticker", chunk.Bytes)
|
||||
}
|
||||
if blobs.getRangeCalls != 0 {
|
||||
t.Fatalf("prewarmed blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls)
|
||||
}
|
||||
|
||||
gotSet, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("resolve found=%v err=%v", found, err)
|
||||
}
|
||||
if gotSet.ID != set.ID || len(docs) != 1 || docs[0].ID != doc.ID {
|
||||
t.Fatalf("resolve = set %+v docs %+v", gotSet, docs)
|
||||
}
|
||||
if counting.getSetByIDCalls != 0 {
|
||||
t.Fatalf("ResolveStickerSet should hit full-set cache, GetStickerSetByID calls = %d", counting.getSetByIDCalls)
|
||||
}
|
||||
docs[0].ID = 999
|
||||
_, docsAgain, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID})
|
||||
if err != nil || !found || docsAgain[0].ID != doc.ID {
|
||||
t.Fatalf("cached docs were mutated: found=%v err=%v docs=%+v", found, err, docsAgain)
|
||||
}
|
||||
}
|
||||
105
internal/app/files/blobfs.go
Normal file
105
internal/app/files/blobfs.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// BlobBackend 是 blob 字节内容的存储后端。第一阶段只有本地磁盘实现。
|
||||
// 内容寻址:Put 返回的 objectKey 是内容 sha256,相同内容自动去重。
|
||||
type BlobBackend interface {
|
||||
Name() string
|
||||
Put(ctx context.Context, data []byte) (objectKey string, err error)
|
||||
Get(ctx context.Context, objectKey string) ([]byte, error)
|
||||
// GetRange 只读 [offset, offset+limit) 段并返回该段字节与文件总大小(limit<=0 读到末尾),
|
||||
// 避免大文件每个 chunk 都整文件读入内存(getFile 按 chunk 多次请求 ⇒ 否则 O(N²) 放大)。
|
||||
GetRange(ctx context.Context, objectKey string, offset, limit int64) (data []byte, total int64, err error)
|
||||
}
|
||||
|
||||
// LocalFS 把 blob 字节存到本地磁盘根目录下,路径按内容 hash 两级 fanout。
|
||||
type LocalFS struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewLocalFS 创建本地磁盘 blob backend,确保根目录存在。
|
||||
func NewLocalFS(root string) (*LocalFS, error) {
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("blob root dir is empty")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create blob root %q: %w", root, err)
|
||||
}
|
||||
return &LocalFS{root: root}, nil
|
||||
}
|
||||
|
||||
// Name 返回后端标识,与 file_blobs.backend 一致。
|
||||
func (l *LocalFS) Name() string { return "localfs" }
|
||||
|
||||
func (l *LocalFS) pathFor(objectKey string) string {
|
||||
if len(objectKey) < 4 {
|
||||
return filepath.Join(l.root, "_", objectKey)
|
||||
}
|
||||
return filepath.Join(l.root, objectKey[:2], objectKey[2:4], objectKey)
|
||||
}
|
||||
|
||||
// Put 写入内容并返回 sha256 hex 作为 objectKey;同内容已存在则跳过写入(去重)。
|
||||
func (l *LocalFS) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
path := l.pathFor(key)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create blob dir: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write blob: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return "", fmt.Errorf("commit blob: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Get 读取 objectKey 对应的全部字节。
|
||||
func (l *LocalFS) Get(_ context.Context, objectKey string) ([]byte, error) {
|
||||
return os.ReadFile(l.pathFor(objectKey))
|
||||
}
|
||||
|
||||
// GetRange 用 ReadAt 只读 [offset, offset+limit) 段,total 取自文件大小;
|
||||
// n 受 total 约束,故即便客户端传超大 limit 也只分配文件实际大小,不会按客户端巨值分配。
|
||||
func (l *LocalFS) GetRange(_ context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) {
|
||||
f, err := os.Open(l.pathFor(objectKey))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := info.Size()
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= total {
|
||||
return []byte{}, total, nil
|
||||
}
|
||||
n := total - offset
|
||||
if limit > 0 && limit < n {
|
||||
n = limit
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
read, err := f.ReadAt(buf, offset)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, 0, err
|
||||
}
|
||||
return buf[:read], total, nil
|
||||
}
|
||||
92
internal/app/files/blobfs_test.go
Normal file
92
internal/app/files/blobfs_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalFSPutGetRoundTrip(t *testing.T) {
|
||||
fs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new local fs: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
data := []byte("hello telesrv media blob 你好")
|
||||
|
||||
key, err := fs.Put(ctx, data)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("empty object key")
|
||||
}
|
||||
|
||||
// 内容寻址:相同内容应得到相同 key(去重)。
|
||||
key2, err := fs.Put(ctx, data)
|
||||
if err != nil {
|
||||
t.Fatalf("put again: %v", err)
|
||||
}
|
||||
if key != key2 {
|
||||
t.Fatalf("expected dedup key %q == %q", key, key2)
|
||||
}
|
||||
|
||||
got, err := fs.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("roundtrip mismatch: got %q want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSDistinctContent(t *testing.T) {
|
||||
fs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new local fs: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
k1, _ := fs.Put(ctx, []byte("aaa"))
|
||||
k2, _ := fs.Put(ctx, []byte("bbb"))
|
||||
if k1 == k2 {
|
||||
t.Fatal("distinct content must yield distinct keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSGetRange(t *testing.T) {
|
||||
fs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new local fs: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key, err := fs.Put(ctx, []byte("0123456789"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
offset, limit int64
|
||||
want string
|
||||
}{
|
||||
{"head", 0, 4, "0123"},
|
||||
{"middle", 3, 4, "3456"},
|
||||
{"limit-exceeds-remaining", 7, 100, "789"},
|
||||
{"zero-limit-reads-to-end", 2, 0, "23456789"},
|
||||
{"offset-at-end", 10, 5, ""},
|
||||
{"offset-past-end", 20, 5, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, total, err := fs.GetRange(ctx, key, tc.offset, tc.limit)
|
||||
if err != nil {
|
||||
t.Fatalf("getrange: %v", err)
|
||||
}
|
||||
if string(data) != tc.want {
|
||||
t.Errorf("data = %q, want %q", data, tc.want)
|
||||
}
|
||||
if total != 10 {
|
||||
t.Errorf("total = %d, want 10", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
6
internal/app/files/doc.go
Normal file
6
internal/app/files/doc.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Package files 是文件应用服务:upload 分片累积、blob 落盘、getFile 下载,
|
||||
// 以及把上传文件组装成 Photo / Document(头像、图片/文件/贴纸消息)。
|
||||
//
|
||||
// 类型边界:本包只用 domain / store 类型,不依赖 tg.*;
|
||||
// rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 的转换。
|
||||
package files
|
||||
278
internal/app/files/photos.go
Normal file
278
internal/app/files/photos.go
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸
|
||||
_ "image/png" // 注册 png DecodeConfig
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。
|
||||
// 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。
|
||||
|
||||
// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。
|
||||
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
photo, err := s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photo.ID, date); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// CreatePhotoFromUpload 把已上传文件组装成 Photo(不绑定 profile_photos),用于频道头像 / 图片消息。
|
||||
func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data))
|
||||
}
|
||||
|
||||
// GetPhoto 按 id 返回已存储照片。
|
||||
func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
|
||||
return s.media.GetPhoto(ctx, id)
|
||||
}
|
||||
|
||||
// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
|
||||
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
|
||||
return s.media.GetDocument(ctx, id)
|
||||
}
|
||||
|
||||
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation
|
||||
// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。
|
||||
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data))
|
||||
}
|
||||
|
||||
// CreateDocumentFromUpload 把已上传文件组装成 Document(文件/视频/音频/gif/贴纸消息),落 blob + documents。
|
||||
func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
docID := randomID()
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: spec.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
doc := domain.Document{
|
||||
ID: docID,
|
||||
AccessHash: randomID(),
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: spec.MimeType,
|
||||
Size: int64(len(data)),
|
||||
DCID: s.dc,
|
||||
Attributes: spec.Attributes,
|
||||
}
|
||||
if spec.Thumb != nil {
|
||||
thumbData, err := s.assembleUpload(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts)
|
||||
if err == nil && len(thumbData) > 0 {
|
||||
thumbKey, err := s.blobs.Put(ctx, thumbData)
|
||||
if err == nil {
|
||||
w, h := imageDimensions(thumbData, 0, 0)
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:m", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: thumbKey,
|
||||
Size: int64(len(thumbData)),
|
||||
MimeType: "image/jpeg",
|
||||
}); err == nil {
|
||||
doc.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。
|
||||
func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) {
|
||||
photo, ok, err := s.media.GetPhoto(ctx, photoID)
|
||||
if err != nil || !ok {
|
||||
return domain.Photo{}, ok, err
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photoID, date); err != nil {
|
||||
return domain.Photo{}, false, err
|
||||
}
|
||||
return photo, true, nil
|
||||
}
|
||||
|
||||
// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。
|
||||
func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) {
|
||||
id, ok, err := s.media.CurrentProfilePhoto(ctx, ownerType, ownerID)
|
||||
if err != nil || !ok {
|
||||
return domain.Photo{}, ok, err
|
||||
}
|
||||
return s.media.GetPhoto(ctx, id)
|
||||
}
|
||||
|
||||
// GetProfilePhotos 返回 owner 的头像历史(最新在前)。
|
||||
func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
||||
ids, total, err := s.media.ListProfilePhotos(ctx, ownerType, ownerID, offset, limit, maxID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
photos := make([]domain.Photo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if p, ok, err := s.media.GetPhoto(ctx, id); err != nil {
|
||||
return nil, 0, err
|
||||
} else if ok {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
return photos, total, nil
|
||||
}
|
||||
|
||||
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
|
||||
func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) {
|
||||
deleted, err := s.media.DeleteProfilePhotos(ctx, ownerType, ownerID, photoIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(deleted), nil
|
||||
}
|
||||
|
||||
// createPhoto 把字节落 blob(每个尺寸一个 location_key,指向同一内容)并写 photos 表。
|
||||
func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec) (domain.Photo, error) {
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
photoID := randomID()
|
||||
sizes := make([]domain.PhotoSize, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: "image/jpeg",
|
||||
}); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)})
|
||||
}
|
||||
photo := domain.Photo{
|
||||
ID: photoID,
|
||||
AccessHash: randomID(),
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
DCID: s.dc,
|
||||
Sizes: sizes,
|
||||
}
|
||||
if err := s.media.PutPhoto(ctx, photo); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
type photoSizeSpec struct {
|
||||
Type string
|
||||
W int
|
||||
H int
|
||||
}
|
||||
|
||||
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
|
||||
w, h := imageDimensions(data, 640, 640)
|
||||
small := 160
|
||||
if w < small {
|
||||
small = w
|
||||
}
|
||||
return []photoSizeSpec{
|
||||
{Type: "a", W: small, H: small},
|
||||
{Type: "c", W: w, H: h},
|
||||
}
|
||||
}
|
||||
|
||||
// photoSizeSpecsForMessage 给图片消息生成下载尺寸('m' 缩略 + 'x'/'y' 大图)。
|
||||
func photoSizeSpecsForMessage(data []byte) []photoSizeSpec {
|
||||
w, h := imageDimensions(data, 1280, 1280)
|
||||
thumbW, thumbH := scaleDown(w, h, 320)
|
||||
return []photoSizeSpec{
|
||||
{Type: "m", W: thumbW, H: thumbH},
|
||||
{Type: "x", W: w, H: h},
|
||||
}
|
||||
}
|
||||
|
||||
func imageDimensions(data []byte, defW, defH int) (int, int) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return defW, defH
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func scaleDown(w, h, max int) (int, int) {
|
||||
if w <= max && h <= max {
|
||||
return w, h
|
||||
}
|
||||
if w >= h {
|
||||
return max, max * h / w
|
||||
}
|
||||
return max * w / h, max
|
||||
}
|
||||
|
||||
func randomID() int64 {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
v := int64(binary.BigEndian.Uint64(b[:]) >> 1)
|
||||
if v == 0 {
|
||||
v = 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func randomFileReference() []byte {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return b
|
||||
}
|
||||
860
internal/app/files/seed.go
Normal file
860
internal/app/files/seed.go
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 本文件实现从外部导出的 reaction / sticker 资源目录导入媒体种子:
|
||||
// JSON 元数据(外部导出 document id/access_hash/file_reference/attributes/thumbs)落 documents /
|
||||
// sticker_sets / available_reactions 表,二进制 .tgs/.webp/缩略图落 blob backend。
|
||||
// dc_id 统一重写为本 server 的 DC,使客户端从本 DC 下载。导入幂等(已存在则跳过)。
|
||||
|
||||
// SeedStats 汇报一次种子导入结果。
|
||||
type SeedStats struct {
|
||||
Reactions int
|
||||
StickerSets int
|
||||
Documents int
|
||||
Blobs int
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// SeedMedia 从导出根目录导入 reaction 与 sticker 资源。maxRegularSets<=0 表示不限。
|
||||
func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int) (SeedStats, error) {
|
||||
var stats SeedStats
|
||||
if root == "" {
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
// 目录不存在:跳过而非失败(开发机可能未放资源)。
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// reactions
|
||||
if n, err := s.media.CountAvailableReactions(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n == 0 {
|
||||
if err := s.seedReactions(ctx, root, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed reactions: %w", err)
|
||||
}
|
||||
} else if incomplete, err := s.availableReactionSeedNeedsRepair(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if incomplete {
|
||||
if err := s.seedReactions(ctx, root, &stats); err != nil {
|
||||
return stats, fmt.Errorf("repair reactions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sticker sets(default 系统集 + 常规集)
|
||||
if n, err := s.media.CountStickerSets(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n == 0 {
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed sticker sets: %w", err)
|
||||
}
|
||||
} else if stale, err := s.stickerSetDocumentThumbsNeedInlineCache(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if stale {
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil {
|
||||
return stats, fmt.Errorf("repair sticker set thumbs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if stats.Reactions == 0 && stats.StickerSets == 0 {
|
||||
stats.Skipped = true
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ---- reactions ----
|
||||
|
||||
func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error {
|
||||
reactionsDir := filepath.Join(root, "telegram_reactions_export", "reactions")
|
||||
rawPath := filepath.Join(root, "telegram_reactions_export", "global_json", "available_reactions_raw.json")
|
||||
raw, err := os.ReadFile(rawPath)
|
||||
if err != nil {
|
||||
return nil // 没有 reaction 资源就跳过
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Reactions []seedReactionJSON `json:"reactions"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("parse available_reactions_raw.json: %w", err)
|
||||
}
|
||||
index, err := scanSeedDir(reactionsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rj := range parsed.Result.Reactions {
|
||||
ar := domain.AvailableReaction{
|
||||
Reaction: rj.Reaction,
|
||||
Title: rj.Title,
|
||||
Inactive: rj.Inactive,
|
||||
Premium: rj.Premium,
|
||||
Order: i,
|
||||
}
|
||||
set := func(dst *int64, d *seedDocumentJSON) error {
|
||||
if d == nil || d.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
doc, err := s.importDocument(ctx, *d, reactionsDir, index, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dst = doc.ID
|
||||
return nil
|
||||
}
|
||||
if err := set(&ar.StaticIconID, rj.StaticIcon); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.AppearAnimationID, rj.AppearAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.SelectAnimationID, rj.SelectAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.ActivateAnimationID, rj.ActivateAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.EffectAnimationID, rj.EffectAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.AroundAnimationID, rj.AroundAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.CenterIconID, rj.CenterIcon); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.media.PutAvailableReaction(ctx, ar); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.Reactions++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, error) {
|
||||
reactions, err := s.media.ListAvailableReactions(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var docIDs []int64
|
||||
for _, r := range reactions {
|
||||
for _, id := range r.DocumentIDs() {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
docIDs = append(docIDs, id)
|
||||
if _, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", id)); err != nil {
|
||||
return false, err
|
||||
} else if !ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if stale, err := s.documentsNeedInlineCachedThumbs(ctx, docIDs); err != nil || stale {
|
||||
return stale, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- sticker sets ----
|
||||
|
||||
func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error {
|
||||
// default 系统集:目录名 → system_key。
|
||||
defaultDir := filepath.Join(root, "telegram_default_stickers_export")
|
||||
order := 0
|
||||
if entries, err := os.ReadDir(defaultDir); err == nil {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
systemKey := systemKeyForDefaultSet(name)
|
||||
setDir := filepath.Join(defaultDir, name)
|
||||
if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil {
|
||||
return fmt.Errorf("import default set %s: %w", name, err)
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// 常规贴纸集。
|
||||
regularDir := filepath.Join(root, "telegram_stickers_export")
|
||||
if entries, err := os.ReadDir(regularDir); err == nil {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
imported := 0
|
||||
for _, name := range names {
|
||||
if maxRegular > 0 && imported >= maxRegular {
|
||||
break
|
||||
}
|
||||
setDir := filepath.Join(regularDir, name)
|
||||
if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil {
|
||||
return fmt.Errorf("import sticker set %s: %w", name, err)
|
||||
}
|
||||
order++
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error {
|
||||
infoPath := filepath.Join(setDir, "set_info.json")
|
||||
raw, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return nil // 该目录无 set_info.json → 跳过
|
||||
}
|
||||
var info struct {
|
||||
Result seedStickerSetResultJSON `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", infoPath, err)
|
||||
}
|
||||
sj := info.Result.Set
|
||||
if sj.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
stickersDir := filepath.Join(setDir, "stickers")
|
||||
index, err := scanSeedDir(stickersDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docIDs := make([]int64, 0, len(info.Result.Documents))
|
||||
docs := make([]domain.Document, 0, len(info.Result.Documents))
|
||||
docIDBySource := make(map[int64]int64, len(info.Result.Documents))
|
||||
for _, dj := range info.Result.Documents {
|
||||
sourceID := dj.ID
|
||||
doc, err := s.importDocument(ctx, dj, stickersDir, index, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc.ID != 0 {
|
||||
docIDBySource[sourceID] = doc.ID
|
||||
docIDs = append(docIDs, doc.ID)
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
}
|
||||
|
||||
kind := stickerSetKind(sj, systemKey)
|
||||
set := domain.StickerSet{
|
||||
ID: sj.ID,
|
||||
AccessHash: sj.AccessHash,
|
||||
ShortName: sj.ShortName,
|
||||
Title: sj.Title,
|
||||
Count: sj.Count,
|
||||
Hash: sj.Hash,
|
||||
Kind: kind,
|
||||
Official: sj.Official,
|
||||
Animated: true, // 导出资源均为 .tgs 动画贴纸
|
||||
Emojis: sj.Emojis,
|
||||
Masks: sj.Masks,
|
||||
Archived: sj.Archived,
|
||||
Installed: seedStickerSetInstalled(kind),
|
||||
ThumbDocumentID: seedDocumentStorageID(derefInt64(sj.ThumbDocumentID)),
|
||||
Thumbs: seedStickerSetPhotoSizes(sj.Thumbs),
|
||||
ThumbDCID: s.dc,
|
||||
ThumbVersion: sj.ThumbVersion,
|
||||
DocumentIDs: docIDs,
|
||||
Packs: seedStickerPacks(sj.Packs, info.Result.Packs, docIDBySource),
|
||||
SortOrder: order,
|
||||
SystemKey: systemKey,
|
||||
}
|
||||
if set.Count == 0 {
|
||||
set.Count = len(docIDs)
|
||||
}
|
||||
if err := s.media.PutStickerSet(ctx, set); err != nil {
|
||||
return err
|
||||
}
|
||||
s.stickerSetCache.put(set, docs)
|
||||
stats.StickerSets++
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 单个 document 导入 ----
|
||||
|
||||
func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDir string, index seedDirIndex, stats *SeedStats) (domain.Document, error) {
|
||||
if dj.ID == 0 {
|
||||
return domain.Document{}, nil
|
||||
}
|
||||
storageID := seedDocumentStorageID(dj.ID)
|
||||
ref, _ := hex.DecodeString(dj.FileReference)
|
||||
doc := domain.Document{
|
||||
ID: storageID,
|
||||
AccessHash: dj.AccessHash,
|
||||
FileReference: ref,
|
||||
Date: parseSeedDate(dj.Date),
|
||||
MimeType: dj.MimeType,
|
||||
Size: dj.Size,
|
||||
DCID: s.dc,
|
||||
Attributes: seedDocumentAttributes(dj.Attributes),
|
||||
}
|
||||
|
||||
// 主体 blob:doc:<server-owned-id>
|
||||
if mainPath, ok := index.main[dj.ID]; ok {
|
||||
data, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", storageID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: dj.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
}
|
||||
|
||||
// 缩略图:PhotoPathSize 内联;小的 PhotoSize 静态图同时写 blob 并作为
|
||||
// PhotoCachedSize 返回,让 TDesktop 处理 document 元数据时即可填本地 image cache。
|
||||
thumbs := make([]domain.PhotoSize, 0, len(dj.Thumbs))
|
||||
for _, tj := range dj.Thumbs {
|
||||
ps, downloadable := seedPhotoSize(tj)
|
||||
if ps.Kind == "" {
|
||||
continue
|
||||
}
|
||||
if downloadable {
|
||||
thumbPath, ok := index.thumb[dj.ID][ps.Type]
|
||||
if !ok {
|
||||
continue // 无可服务的缩略图文件,丢弃该尺寸(保留 PhotoPathSize 占位)
|
||||
}
|
||||
data, err := os.ReadFile(thumbPath)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ps.Size = len(data)
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", storageID, ps.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: seedThumbMimeType(data),
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ps = seedInlineCachedDocumentThumb(ps, data)
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
}
|
||||
thumbs = append(thumbs, ps)
|
||||
}
|
||||
doc.Thumbs = seedPreferRasterDocumentThumbs(thumbs)
|
||||
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
stats.Documents++
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmSmallBlob(objectKey string, data []byte) {
|
||||
if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes {
|
||||
s.byteCache.put(objectKey, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 目录扫描:docID → 主体文件 / 可下载缩略图 ----
|
||||
|
||||
type seedDirIndex struct {
|
||||
main map[int64]string // docID -> 主体文件路径
|
||||
thumb map[int64]map[string]string // docID -> thumbType -> 缩略图文件路径
|
||||
}
|
||||
|
||||
var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`)
|
||||
var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`)
|
||||
|
||||
const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
|
||||
|
||||
// Exported Telegram resources keep their original id in filenames/JSON, but
|
||||
// telesrv owns the document catalog it serves. Imported high source ids are
|
||||
// normalized once at seed time so RPC/storage use one server-owned id.
|
||||
const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000
|
||||
|
||||
var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`)
|
||||
|
||||
func seedDocumentStorageID(sourceID int64) int64 {
|
||||
if sourceID <= 0 {
|
||||
return 0
|
||||
}
|
||||
if sourceID > seedExternalDocumentIDOffset {
|
||||
return sourceID - seedExternalDocumentIDOffset
|
||||
}
|
||||
return sourceID
|
||||
}
|
||||
|
||||
func scanSeedDir(dir string) (seedDirIndex, error) {
|
||||
idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return idx, nil // 目录不存在 → 空 index
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
full := filepath.Join(dir, name)
|
||||
if marker := seedThumbMarker.FindStringIndex(name); marker != nil {
|
||||
// 只收可下载的 PhotoSize 缩略图(jpg);PhotoPathSize(svg) 内联在 JSON。
|
||||
if ext != ".jpg" && ext != ".jpeg" {
|
||||
continue
|
||||
}
|
||||
m := seedThumbType.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
docID := docIDFromName(name[:marker[0]])
|
||||
if docID == 0 {
|
||||
continue
|
||||
}
|
||||
if idx.thumb[docID] == nil {
|
||||
idx.thumb[docID] = map[string]string{}
|
||||
}
|
||||
idx.thumb[docID][m[1]] = full
|
||||
continue
|
||||
}
|
||||
if ext == ".svg" || ext == ".json" {
|
||||
continue
|
||||
}
|
||||
docID := docIDFromName(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
if docID == 0 {
|
||||
continue
|
||||
}
|
||||
idx.main[docID] = full
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// docIDFromName 取 base name 中末尾最长的数字串作为 document id。
|
||||
func docIDFromName(base string) int64 {
|
||||
matches := seedTrailingDigits.FindAllString(base, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
id, err := strconv.ParseInt(last, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func systemKeyForDefaultSet(dirName string) string {
|
||||
switch dirName {
|
||||
case "DefaultSet_AnimatedEmoji":
|
||||
return "animated_emoji"
|
||||
case "DefaultSet_AnimatedEmojiAnimations":
|
||||
return "animated_emoji_animations"
|
||||
case "DefaultSet_EmojiGenericAnimations":
|
||||
return "emoji_generic_animations"
|
||||
case "DefaultSet_Dice_Normal":
|
||||
return "dice:\U0001f3b2"
|
||||
case "DefaultSet_Dice_Dart":
|
||||
return "dice:\U0001f3af"
|
||||
case "DefaultSet_Dice_Basketball":
|
||||
return "dice:\U0001f3c0"
|
||||
case "DefaultSet_Dice_Football":
|
||||
return "dice:⚽"
|
||||
case "DefaultSet_Dice_Bowling":
|
||||
return "dice:\U0001f3b3"
|
||||
case "DefaultSet_Dice_Casino":
|
||||
return "dice:\U0001f3b0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind {
|
||||
switch {
|
||||
case systemKey != "":
|
||||
return domain.StickerSetKindSystem
|
||||
case sj.Emojis:
|
||||
return domain.StickerSetKindEmoji
|
||||
case sj.Masks:
|
||||
return domain.StickerSetKindMasks
|
||||
default:
|
||||
return domain.StickerSetKindStickers
|
||||
}
|
||||
}
|
||||
|
||||
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
|
||||
return kind != domain.StickerSetKindSystem
|
||||
}
|
||||
|
||||
// ---- JSON → domain 转换 ----
|
||||
|
||||
func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute {
|
||||
out := make([]domain.DocumentAttribute, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
switch a.Type {
|
||||
case "DocumentAttributeImageSize":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: a.W, H: a.H})
|
||||
case "DocumentAttributeAnimated":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
|
||||
case "DocumentAttributeSticker":
|
||||
attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: a.Alt, Mask: a.Mask}
|
||||
if a.Stickerset != nil {
|
||||
attr.StickerSetID = a.Stickerset.ID
|
||||
attr.StickerSetAccessHash = a.Stickerset.AccessHash
|
||||
}
|
||||
out = append(out, attr)
|
||||
case "DocumentAttributeCustomEmoji":
|
||||
attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: a.Alt, Free: a.Free, TextColor: a.TextColor}
|
||||
if a.Stickerset != nil {
|
||||
attr.StickerSetID = a.Stickerset.ID
|
||||
attr.StickerSetAccessHash = a.Stickerset.AccessHash
|
||||
}
|
||||
out = append(out, attr)
|
||||
case "DocumentAttributeVideo":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming})
|
||||
case "DocumentAttributeAudio":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer})
|
||||
case "DocumentAttributeFilename":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: a.FileName})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize {
|
||||
out := make([]domain.PhotoSize, 0, len(thumbs))
|
||||
for _, t := range thumbs {
|
||||
ps, _ := seedPhotoSize(t)
|
||||
if ps.Kind != "" {
|
||||
out = append(out, ps)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedStickerSetPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize {
|
||||
out := make([]domain.PhotoSize, 0, len(thumbs))
|
||||
for _, t := range thumbs {
|
||||
ps, downloadable := seedPhotoSize(t)
|
||||
if ps.Kind == "" || downloadable {
|
||||
continue
|
||||
}
|
||||
out = append(out, ps)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPhotoSize(t seedThumbJSON) (domain.PhotoSize, bool) {
|
||||
switch t.Type {
|
||||
case "PhotoSize":
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: t.SizeType, W: t.W, H: t.H, Size: t.Size}, true
|
||||
case "PhotoStrippedSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindStripped, Type: t.SizeType, Bytes: b}, false
|
||||
case "PhotoCachedSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindCached, Type: t.SizeType, W: t.W, H: t.H, Bytes: b}, false
|
||||
case "PhotoPathSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindPath, Type: t.SizeType, Bytes: b}, false
|
||||
case "PhotoSizeProgressive":
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindProgressive, Type: t.SizeType, W: t.W, H: t.H, Sizes: t.Sizes}, true
|
||||
default:
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.PhotoSize {
|
||||
if ps.Kind != domain.PhotoSizeKindDefault || len(data) == 0 || len(data) > seedInlineCachedDocumentThumbMaxBytes {
|
||||
return ps
|
||||
}
|
||||
ps.Kind = domain.PhotoSizeKindCached
|
||||
ps.Size = 0
|
||||
ps.Bytes = append([]byte(nil), data...)
|
||||
return ps
|
||||
}
|
||||
|
||||
func seedPreferRasterDocumentThumbs(sizes []domain.PhotoSize) []domain.PhotoSize {
|
||||
if !documentThumbsHaveRaster(sizes) {
|
||||
return sizes
|
||||
}
|
||||
out := sizes[:0]
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath {
|
||||
continue
|
||||
}
|
||||
out = append(out, size)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedThumbMimeType(data []byte) string {
|
||||
switch {
|
||||
case 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 "image/webp"
|
||||
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "image/jpeg"
|
||||
case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G':
|
||||
return "image/png"
|
||||
case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F':
|
||||
return "image/gif"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) {
|
||||
var ids []int64
|
||||
for _, kind := range []domain.StickerSetKind{
|
||||
domain.StickerSetKindStickers,
|
||||
domain.StickerSetKindEmoji,
|
||||
domain.StickerSetKindMasks,
|
||||
domain.StickerSetKindSystem,
|
||||
} {
|
||||
sets, err := s.media.ListStickerSets(ctx, kind)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, set := range sets {
|
||||
ids = append(ids, set.DocumentIDs...)
|
||||
}
|
||||
}
|
||||
return s.documentsNeedInlineCachedThumbs(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
unique := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, unique)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if documentThumbsHaveRaster(doc.Thumbs) && documentThumbsHavePath(doc.Thumbs) {
|
||||
return true, nil
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes {
|
||||
return true, nil
|
||||
}
|
||||
if thumb.Kind == domain.PhotoSizeKindCached && len(thumb.Bytes) > 0 {
|
||||
blob, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
want := seedThumbMimeType(thumb.Bytes)
|
||||
if want != "application/octet-stream" && blob.MimeType != want {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func documentThumbsHaveRaster(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
case domain.PhotoSizeKindDefault:
|
||||
if size.Type != "" && size.Size > 0 {
|
||||
return true
|
||||
}
|
||||
case domain.PhotoSizeKindProgressive:
|
||||
if size.Type != "" && len(size.Sizes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func documentThumbsHavePath(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func seedStickerPacks(setPacks, resultPacks []seedStickerPackJSON, docIDBySource map[int64]int64) []domain.StickerPack {
|
||||
packs := setPacks
|
||||
if len(packs) == 0 {
|
||||
packs = resultPacks
|
||||
}
|
||||
out := make([]domain.StickerPack, 0, len(packs))
|
||||
for _, p := range packs {
|
||||
documents := make([]int64, 0, len(p.Documents))
|
||||
for _, sourceID := range p.Documents {
|
||||
if id, ok := docIDBySource[sourceID]; ok {
|
||||
documents = append(documents, id)
|
||||
continue
|
||||
}
|
||||
if id := seedDocumentStorageID(sourceID); id != 0 {
|
||||
documents = append(documents, id)
|
||||
}
|
||||
}
|
||||
out = append(out, domain.StickerPack{Emoticon: p.Emoticon, DocumentIDs: documents})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseSeedDate(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return int(t.Unix())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ---- seed JSON 结构 ----
|
||||
|
||||
type seedInputStickerSetJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
}
|
||||
|
||||
type seedAttrJSON struct {
|
||||
Type string `json:"_"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Alt string `json:"alt"`
|
||||
Mask bool `json:"mask"`
|
||||
Duration float64 `json:"duration"`
|
||||
RoundMessage bool `json:"round_message"`
|
||||
SupportsStreaming bool `json:"supports_streaming"`
|
||||
Voice bool `json:"voice"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
FileName string `json:"file_name"`
|
||||
Free bool `json:"free"`
|
||||
TextColor bool `json:"text_color"`
|
||||
Stickerset *seedInputStickerSetJSON `json:"stickerset"`
|
||||
}
|
||||
|
||||
type seedThumbJSON struct {
|
||||
Type string `json:"_"`
|
||||
SizeType string `json:"type"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Size int `json:"size"`
|
||||
Bytes string `json:"bytes"`
|
||||
Sizes []int `json:"sizes"`
|
||||
}
|
||||
|
||||
type seedDocumentJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference string `json:"file_reference"`
|
||||
Date string `json:"date"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
DCID int `json:"dc_id"`
|
||||
Attributes []seedAttrJSON `json:"attributes"`
|
||||
Thumbs []seedThumbJSON `json:"thumbs"`
|
||||
}
|
||||
|
||||
type seedStickerPackJSON struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
Documents []int64 `json:"documents"`
|
||||
}
|
||||
|
||||
type seedStickerSetJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
Title string `json:"title"`
|
||||
ShortName string `json:"short_name"`
|
||||
Count int `json:"count"`
|
||||
Hash int `json:"hash"`
|
||||
Archived bool `json:"archived"`
|
||||
Official bool `json:"official"`
|
||||
Masks bool `json:"masks"`
|
||||
Emojis bool `json:"emojis"`
|
||||
Thumbs []seedThumbJSON `json:"thumbs"`
|
||||
ThumbDCID int `json:"thumb_dc_id"`
|
||||
ThumbVersion int `json:"thumb_version"`
|
||||
ThumbDocumentID *int64 `json:"thumb_document_id"`
|
||||
Packs []seedStickerPackJSON `json:"packs"`
|
||||
}
|
||||
|
||||
type seedStickerSetResultJSON struct {
|
||||
Set seedStickerSetJSON `json:"set"`
|
||||
Packs []seedStickerPackJSON `json:"packs"`
|
||||
Documents []seedDocumentJSON `json:"documents"`
|
||||
}
|
||||
|
||||
type seedReactionJSON struct {
|
||||
Reaction string `json:"reaction"`
|
||||
Title string `json:"title"`
|
||||
Inactive bool `json:"inactive"`
|
||||
Premium bool `json:"premium"`
|
||||
StaticIcon *seedDocumentJSON `json:"static_icon"`
|
||||
AppearAnimation *seedDocumentJSON `json:"appear_animation"`
|
||||
SelectAnimation *seedDocumentJSON `json:"select_animation"`
|
||||
ActivateAnimation *seedDocumentJSON `json:"activate_animation"`
|
||||
EffectAnimation *seedDocumentJSON `json:"effect_animation"`
|
||||
AroundAnimation *seedDocumentJSON `json:"around_animation"`
|
||||
CenterIcon *seedDocumentJSON `json:"center_icon"`
|
||||
}
|
||||
526
internal/app/files/seed_test.go
Normal file
526
internal/app/files/seed_test.go
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。
|
||||
type fakeMediaStore struct {
|
||||
mu sync.Mutex
|
||||
blobs map[string]domain.FileBlob
|
||||
docs map[int64]domain.Document
|
||||
photos map[int64]domain.Photo
|
||||
sets map[int64]domain.StickerSet
|
||||
reactions []domain.AvailableReaction
|
||||
parts map[string][]domain.UploadPart
|
||||
}
|
||||
|
||||
func newFakeMediaStore() *fakeMediaStore {
|
||||
return &fakeMediaStore{
|
||||
blobs: map[string]domain.FileBlob{},
|
||||
docs: map[int64]domain.Document{},
|
||||
photos: map[int64]domain.Photo{},
|
||||
sets: map[int64]domain.StickerSet{},
|
||||
parts: map[string][]domain.UploadPart{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil }
|
||||
func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil }
|
||||
|
||||
func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.blobs[blob.LocationKey] = blob
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.FileBlob, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b, ok := f.blobs[key]
|
||||
return b, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.docs[doc.ID] = doc
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
d, ok := f.docs[id]
|
||||
return d, ok, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
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 *fakeMediaStore) PutPhoto(_ context.Context, p domain.Photo) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.photos[p.ID] = p
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
p, ok := f.photos[id]
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.sets[set.ID] = set
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sets[id]
|
||||
return s, ok, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sets {
|
||||
if s.ShortName == name {
|
||||
return s, true, nil
|
||||
}
|
||||
}
|
||||
return domain.StickerSet{}, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetBySystemKey(_ context.Context, key string) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sets {
|
||||
if s.SystemKey == key {
|
||||
return s, true, nil
|
||||
}
|
||||
}
|
||||
return domain.StickerSet{}, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []domain.StickerSet
|
||||
for _, s := range f.sets {
|
||||
if s.Kind == kind {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.sets), nil
|
||||
}
|
||||
func (f *fakeMediaStore) PutAvailableReaction(_ context.Context, r domain.AvailableReaction) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, existing := range f.reactions {
|
||||
if existing.Reaction == r.Reaction {
|
||||
f.reactions[i] = r
|
||||
return nil
|
||||
}
|
||||
}
|
||||
f.reactions = append(f.reactions, r)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListAvailableReactions(_ context.Context) ([]domain.AvailableReaction, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]domain.AvailableReaction(nil), f.reactions...), nil
|
||||
}
|
||||
func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.reactions), nil
|
||||
}
|
||||
func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) {
|
||||
return 0, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||
seedDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(seedDir, "telegram_reactions_export", "global_json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reactionsDir := filepath.Join(seedDir, "telegram_reactions_export", "reactions")
|
||||
if err := os.MkdirAll(reactionsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := `{"result":{"reactions":[{"reaction":"👍","title":"Like","static_icon":{"id":1111111,"access_hash":1,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"image/webp","size":4,"attributes":[],"thumbs":[]},"select_animation":{"id":2222222,"access_hash":2,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"attributes":[],"thumbs":[]}}]}}`
|
||||
if err := os.WriteFile(filepath.Join(seedDir, "telegram_reactions_export", "global_json", "available_reactions_raw.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111.webp"), []byte("webp"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111_thumb1_PhotoSize_types_72x72.jpg"), []byte("jpeg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_select_2222222.tgs"), []byte("tgs!"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
media := newFakeMediaStore()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
svc := NewService(media, blobs, 2)
|
||||
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
|
||||
t.Fatalf("initial seed: %v", err)
|
||||
} else if stats.Reactions != 1 || stats.Blobs != 2 {
|
||||
t.Fatalf("initial stats = %+v, want one reaction and two blobs", stats)
|
||||
}
|
||||
chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("prewarmed getfile ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(chunk.Bytes) != "tgs!" {
|
||||
t.Fatalf("prewarmed chunk = %q, want tgs!", chunk.Bytes)
|
||||
}
|
||||
if blobs.getRangeCalls != 0 {
|
||||
t.Fatalf("seeded small blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls)
|
||||
}
|
||||
|
||||
media.mu.Lock()
|
||||
delete(media.blobs, "doc:2222222")
|
||||
media.mu.Unlock()
|
||||
|
||||
stats, err := svc.SeedMedia(context.Background(), seedDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("repair seed: %v", err)
|
||||
}
|
||||
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
|
||||
t.Fatalf("repair stats = %+v, want repair import", stats)
|
||||
}
|
||||
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
||||
t.Fatal("missing reaction blob was not repaired")
|
||||
}
|
||||
if reactions, _ := media.ListAvailableReactions(context.Background()); len(reactions) != 1 {
|
||||
t.Fatalf("reaction upsert duplicated rows: got %d", len(reactions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaFromRealExport(t *testing.T) {
|
||||
seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR")
|
||||
if seedDir == "" {
|
||||
t.Skip("TELESRV_REAL_STICKER_SEED_DIR not set")
|
||||
}
|
||||
if _, err := os.Stat(seedDir); err != nil {
|
||||
t.Skipf("seed dir %s not present: %v", seedDir, err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 2)
|
||||
stats, err := svc.SeedMedia(context.Background(), seedDir, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("seed media: %v", err)
|
||||
}
|
||||
t.Logf("seed stats: reactions=%d sets=%d docs=%d blobs=%d", stats.Reactions, stats.StickerSets, stats.Documents, stats.Blobs)
|
||||
if stats.Reactions == 0 {
|
||||
t.Error("expected reactions imported")
|
||||
}
|
||||
if stats.StickerSets == 0 {
|
||||
t.Error("expected sticker sets imported")
|
||||
}
|
||||
if stats.Documents == 0 {
|
||||
t.Error("expected documents imported")
|
||||
}
|
||||
if stats.Blobs == 0 {
|
||||
t.Error("expected blobs imported")
|
||||
}
|
||||
|
||||
// reaction 引用的文档应能被解析回真实 document(带 sticker 属性 + 主体 blob)。
|
||||
reactions, _ := media.ListAvailableReactions(context.Background())
|
||||
if len(reactions) == 0 {
|
||||
t.Fatal("no reactions stored")
|
||||
}
|
||||
first := reactions[0]
|
||||
if first.Reaction == "" {
|
||||
t.Error("reaction emoticon empty")
|
||||
}
|
||||
if first.StaticIconID == 0 || first.SelectAnimationID == 0 {
|
||||
t.Error("reaction missing document ids")
|
||||
}
|
||||
if d, ok, _ := media.GetDocument(context.Background(), first.SelectAnimationID); !ok {
|
||||
t.Error("reaction select animation document missing")
|
||||
} else {
|
||||
if d.ID > seedExternalDocumentIDOffset {
|
||||
t.Errorf("reaction document kept external source id: %d", d.ID)
|
||||
}
|
||||
if d.DCID != 2 {
|
||||
t.Errorf("document dc_id not rewritten: %d", d.DCID)
|
||||
}
|
||||
if _, ok, _ := media.GetFileBlob(context.Background(), blobKeyDoc(d.ID)); !ok {
|
||||
t.Errorf("reaction document %d main blob missing", d.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 一个常规贴纸集应有 documents 且能按 short_name 解析。
|
||||
for _, s := range media.sets {
|
||||
for _, thumb := range s.Thumbs {
|
||||
if thumb.Downloadable() {
|
||||
t.Fatalf("sticker set %s exposes downloadable cover thumb %q without a serviceable blob", s.ShortName, thumb.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sample domain.StickerSet
|
||||
for _, s := range media.sets {
|
||||
if s.Kind == domain.StickerSetKindStickers && len(s.DocumentIDs) > 0 {
|
||||
sample = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if sample.ID == 0 {
|
||||
t.Fatal("no regular sticker set with documents imported")
|
||||
}
|
||||
if got, ok, _ := media.GetStickerSetByShortName(context.Background(), sample.ShortName); !ok || got.ID != sample.ID {
|
||||
t.Error("sticker set not resolvable by short name")
|
||||
}
|
||||
if doc, ok, _ := media.GetDocument(context.Background(), sample.DocumentIDs[0]); !ok {
|
||||
t.Fatalf("sample sticker document %d missing", sample.DocumentIDs[0])
|
||||
} else {
|
||||
if doc.ID > seedExternalDocumentIDOffset {
|
||||
t.Fatalf("sample sticker kept external source id: %d", doc.ID)
|
||||
}
|
||||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||||
if !ok {
|
||||
t.Fatalf("sample sticker document thumbs are not inline cached: %+v", doc.Thumbs)
|
||||
}
|
||||
blob, ok, err := media.GetFileBlob(context.Background(), blobKeyDoc(doc.ID)+":"+thumb.Type)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("sample sticker thumb blob ok=%v err=%v", ok, err)
|
||||
}
|
||||
if want := seedThumbMimeType(thumb.Bytes); blob.MimeType != want {
|
||||
t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want)
|
||||
}
|
||||
if hasPathThumb(doc.Thumbs) {
|
||||
t.Fatalf("sample sticker document still exposes path thumb together with raster: %+v", doc.Thumbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) {
|
||||
const sourceID int64 = 5382305375846410902
|
||||
const want int64 = 1382305375846410902
|
||||
if got := seedDocumentStorageID(sourceID); got != want {
|
||||
t.Fatalf("seedDocumentStorageID(%d) = %d, want %d", sourceID, got, want)
|
||||
}
|
||||
if got := seedDocumentStorageID(2222222); got != 2222222 {
|
||||
t.Fatalf("small server id changed: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind domain.StickerSetKind
|
||||
want bool
|
||||
}{
|
||||
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: true},
|
||||
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true},
|
||||
{name: "masks", kind: domain.StickerSetKindMasks, want: true},
|
||||
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := seedStickerSetInstalled(tc.kind); got != tc.want {
|
||||
t.Fatalf("seedStickerSetInstalled(%q) = %v, want %v", tc.kind, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedInlineCachedDocumentThumb(t *testing.T) {
|
||||
input := domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 6400}
|
||||
got := seedInlineCachedDocumentThumb(input, []byte("jpeg"))
|
||||
if got.Kind != domain.PhotoSizeKindCached {
|
||||
t.Fatalf("kind = %q, want cached", got.Kind)
|
||||
}
|
||||
if got.Size != 0 || string(got.Bytes) != "jpeg" {
|
||||
t.Fatalf("cached thumb = %+v, want inline bytes without downloadable size", got)
|
||||
}
|
||||
large := make([]byte, seedInlineCachedDocumentThumbMaxBytes+1)
|
||||
if got := seedInlineCachedDocumentThumb(input, large); got.Kind != domain.PhotoSizeKindDefault || got.Size != input.Size || len(got.Bytes) != 0 {
|
||||
t.Fatalf("large thumb = %+v, want unchanged downloadable thumb", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedThumbMimeType(t *testing.T) {
|
||||
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
|
||||
if got := seedThumbMimeType(webp); got != "image/webp" {
|
||||
t.Fatalf("webp mime = %q, want image/webp", got)
|
||||
}
|
||||
jpeg := []byte{0xFF, 0xD8, 0xFF}
|
||||
if got := seedThumbMimeType(jpeg); got != "image/jpeg" {
|
||||
t.Fatalf("jpeg mime = %q, want image/jpeg", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists(t *testing.T) {
|
||||
sizes := []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")},
|
||||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")},
|
||||
}
|
||||
got := seedPreferRasterDocumentThumbs(sizes)
|
||||
if hasPathThumb(got) {
|
||||
t.Fatalf("path thumb should be dropped when raster exists: %+v", got)
|
||||
}
|
||||
if !hasCachedThumb(got) {
|
||||
t.Fatalf("cached thumb should be kept: %+v", got)
|
||||
}
|
||||
|
||||
onlyPath := []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}}
|
||||
if got := seedPreferRasterDocumentThumbs(onlyPath); !hasPathThumb(got) {
|
||||
t.Fatalf("path-only thumbs should be kept: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
|
||||
doc := domain.Document{
|
||||
ID: 100,
|
||||
Thumbs: []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: webp},
|
||||
},
|
||||
}
|
||||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/jpeg"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
svc := NewService(media, nil, 2)
|
||||
stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("documentsNeedInlineCachedThumbs: %v", err)
|
||||
}
|
||||
if !stale {
|
||||
t.Fatal("expected stale mime to require repair")
|
||||
}
|
||||
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil {
|
||||
t.Fatalf("put repaired blob: %v", err)
|
||||
}
|
||||
stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("documentsNeedInlineCachedThumbs after repair: %v", err)
|
||||
}
|
||||
if stale {
|
||||
t.Fatal("repaired mime should not require repair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
doc := domain.Document{
|
||||
ID: 100,
|
||||
Thumbs: []domain.PhotoSize{
|
||||
{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")},
|
||||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")},
|
||||
},
|
||||
}
|
||||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
svc := NewService(media, nil, 2)
|
||||
stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("documentsNeedInlineCachedThumbs: %v", err)
|
||||
}
|
||||
if !stale {
|
||||
t.Fatal("path thumb with raster should require repair")
|
||||
}
|
||||
}
|
||||
|
||||
func hasCachedThumb(sizes []domain.PhotoSize) bool {
|
||||
_, ok := findCachedThumb(sizes)
|
||||
return ok
|
||||
}
|
||||
|
||||
func findCachedThumb(sizes []domain.PhotoSize) (domain.PhotoSize, bool) {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindCached && len(size.Bytes) > 0 {
|
||||
return size, true
|
||||
}
|
||||
}
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
|
||||
func hasPathThumb(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func blobKeyDoc(id int64) string {
|
||||
return "doc:" + itoa(id)
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
256
internal/app/files/service.go
Normal file
256
internal/app/files/service.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。
|
||||
const (
|
||||
MaxUploadPartBytes = 524288 // 512KB
|
||||
MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体
|
||||
)
|
||||
|
||||
// blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。
|
||||
const blobMetaCacheCapacity = 1 << 16
|
||||
|
||||
// 小文件热缓存只覆盖 sticker/reaction/thumbnail 一类不可变小 blob;大媒体继续分段读。
|
||||
const (
|
||||
blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB
|
||||
blobBytesCacheMaxBytes = 64 << 20 // 64MB
|
||||
)
|
||||
|
||||
// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。
|
||||
type Service struct {
|
||||
media store.MediaStore
|
||||
blobs BlobBackend
|
||||
dc int
|
||||
blobCache *blobMetaCache
|
||||
byteCache *blobBytesCache
|
||||
stickerSetCache *stickerSetFullCache
|
||||
}
|
||||
|
||||
// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。
|
||||
func NewService(media store.MediaStore, blobs BlobBackend, dc int) *Service {
|
||||
return &Service{
|
||||
media: media,
|
||||
blobs: blobs,
|
||||
dc: dc,
|
||||
blobCache: newBlobMetaCache(blobMetaCacheCapacity),
|
||||
byteCache: newBlobBytesCache(blobBytesCacheMaxBytes),
|
||||
stickerSetCache: newStickerSetFullCache(),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveFilePart 累积一个 small file 分片。
|
||||
func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) {
|
||||
if err := validatePart(part, len(bytes)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
|
||||
OwnerUserID: ownerUserID,
|
||||
FileID: fileID,
|
||||
Part: part,
|
||||
Bytes: bytes,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SaveBigFilePart 累积一个 big file 分片(带已知总分片数)。
|
||||
func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) {
|
||||
if err := validatePart(part, len(bytes)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if totalParts <= 0 || totalParts > MaxUploadParts {
|
||||
return false, domain.ErrFilePartsInvalid
|
||||
}
|
||||
if err := s.media.SaveFilePart(ctx, domain.UploadPart{
|
||||
OwnerUserID: ownerUserID,
|
||||
FileID: fileID,
|
||||
Part: part,
|
||||
TotalParts: totalParts,
|
||||
Big: true,
|
||||
Bytes: bytes,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。
|
||||
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
|
||||
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
|
||||
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
|
||||
blob, ok := s.blobCache.get(req.LocationKey)
|
||||
if !ok {
|
||||
var (
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
blob, found, err = s.media.GetFileBlob(ctx, req.LocationKey)
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.FileChunk{}, false, nil
|
||||
}
|
||||
s.blobCache.put(req.LocationKey, blob)
|
||||
}
|
||||
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
|
||||
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
|
||||
return domain.FileChunk{
|
||||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||||
MimeType: blob.MimeType,
|
||||
Total: int64(len(data)),
|
||||
}, true, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||||
s.byteCache.put(blob.ObjectKey, data)
|
||||
return domain.FileChunk{
|
||||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||||
MimeType: blob.MimeType,
|
||||
Total: total,
|
||||
}, true, nil
|
||||
}
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
return domain.FileChunk{
|
||||
Bytes: data,
|
||||
MimeType: blob.MimeType,
|
||||
Total: total,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func sliceBlobBytes(data []byte, offset, limit int64) []byte {
|
||||
total := int64(len(data))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= total {
|
||||
return []byte{}
|
||||
}
|
||||
end := total
|
||||
if limit > 0 && offset+limit < end {
|
||||
end = offset + limit
|
||||
}
|
||||
return append([]byte(nil), data[offset:end]...)
|
||||
}
|
||||
|
||||
// ---- 资源读取(reaction / sticker / document)----
|
||||
|
||||
// ListAvailableReactions 返回可用 reaction 目录(带真实文档 id)。
|
||||
func (s *Service) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) {
|
||||
return s.media.ListAvailableReactions(ctx)
|
||||
}
|
||||
|
||||
// GetDocuments 按 id 批量加载文档(自定义 emoji / 贴纸)。
|
||||
func (s *Service) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) {
|
||||
return s.media.GetDocuments(ctx, ids)
|
||||
}
|
||||
|
||||
// ListStickerSets 列出某类贴纸集(用于 getAllStickers 等)。
|
||||
func (s *Service) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
||||
return s.media.ListStickerSets(ctx, kind)
|
||||
}
|
||||
|
||||
// ResolveStickerSet 按 ref 解析贴纸集,并按 DocumentIDs 顺序加载其文档。
|
||||
func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
|
||||
if set, docs, ok := s.stickerSetCache.get(ref); ok {
|
||||
return set, docs, true, nil
|
||||
}
|
||||
var (
|
||||
set domain.StickerSet
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
switch ref.Kind {
|
||||
case domain.StickerSetRefByID:
|
||||
set, found, err = s.media.GetStickerSetByID(ctx, ref.ID)
|
||||
case domain.StickerSetRefByShortName:
|
||||
set, found, err = s.media.GetStickerSetByShortName(ctx, ref.ShortName)
|
||||
case domain.StickerSetRefBySystem:
|
||||
set, found, err = s.media.GetStickerSetBySystemKey(ctx, ref.SystemKey)
|
||||
default:
|
||||
return domain.StickerSet{}, nil, false, nil
|
||||
}
|
||||
if err != nil || !found {
|
||||
return domain.StickerSet{}, nil, found, err
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
|
||||
if err != nil {
|
||||
return domain.StickerSet{}, nil, false, err
|
||||
}
|
||||
ordered := orderDocuments(docs, set.DocumentIDs)
|
||||
s.stickerSetCache.put(set, ordered)
|
||||
return set, ordered, true, nil
|
||||
}
|
||||
|
||||
// orderDocuments 把无序的文档按 ids 顺序重排(GetDocuments 用 ANY 查询不保证顺序)。
|
||||
func orderDocuments(docs []domain.Document, ids []int64) []domain.Document {
|
||||
byID := make(map[int64]domain.Document, len(docs))
|
||||
for _, d := range docs {
|
||||
byID[d.ID] = d
|
||||
}
|
||||
out := make([]domain.Document, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if d, ok := byID[id]; ok {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assembleUpload 把已上传分片按 part 顺序拼成完整字节,并清理分片。
|
||||
// expectedParts>0 时校验分片连续且齐全。
|
||||
func (s *Service) assembleUpload(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]byte, error) {
|
||||
parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil, domain.ErrFilePartsInvalid
|
||||
}
|
||||
if expectedParts > 0 && len(parts) != expectedParts {
|
||||
return nil, domain.ErrFilePartsInvalid
|
||||
}
|
||||
total := 0
|
||||
for i, p := range parts {
|
||||
if p.Part != i {
|
||||
return nil, domain.ErrFilePartsInvalid // 缺片或乱序
|
||||
}
|
||||
total += len(p.Bytes)
|
||||
}
|
||||
buf := make([]byte, 0, total)
|
||||
for _, p := range parts {
|
||||
buf = append(buf, p.Bytes...)
|
||||
}
|
||||
if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func validatePart(part, size int) error {
|
||||
if part < 0 || part >= MaxUploadParts {
|
||||
return domain.ErrFilePartInvalid
|
||||
}
|
||||
if size == 0 {
|
||||
return domain.ErrFilePartInvalid
|
||||
}
|
||||
if size > MaxUploadPartBytes {
|
||||
return domain.ErrFilePartTooBig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
136
internal/app/files/warm.go
Normal file
136
internal/app/files/warm.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// WarmStats 汇报一次启动资源缓存预热结果。
|
||||
type WarmStats struct {
|
||||
StickerSets int
|
||||
Documents int
|
||||
Blobs int
|
||||
}
|
||||
|
||||
// WarmCaches 从已持久化的 sticker/reaction 元数据预热小 blob 字节缓存与完整 sticker set 缓存。
|
||||
// SeedMedia 在已有数据时会跳过导入;该方法保证普通 server 重启后历史 sticker 首次渲染也不是冷缓存。
|
||||
func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) {
|
||||
var stats WarmStats
|
||||
seenDocs := make(map[int64]struct{})
|
||||
for _, kind := range []domain.StickerSetKind{
|
||||
domain.StickerSetKindStickers,
|
||||
domain.StickerSetKindEmoji,
|
||||
domain.StickerSetKindMasks,
|
||||
domain.StickerSetKindSystem,
|
||||
} {
|
||||
sets, err := s.media.ListStickerSets(ctx, kind)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
for _, set := range sets {
|
||||
docs, err := s.media.GetDocuments(ctx, set.DocumentIDs)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
ordered := orderDocuments(docs, set.DocumentIDs)
|
||||
s.stickerSetCache.put(set, ordered)
|
||||
stats.StickerSets++
|
||||
for _, doc := range ordered {
|
||||
if _, ok := seenDocs[doc.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenDocs[doc.ID] = struct{}{}
|
||||
stats.Documents++
|
||||
warmed, err := s.prewarmDocumentBlobs(ctx, doc)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Blobs += warmed
|
||||
}
|
||||
}
|
||||
}
|
||||
reactions, err := s.media.ListAvailableReactions(ctx)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
reactionIDs := make([]int64, 0, len(reactions)*4)
|
||||
for _, reaction := range reactions {
|
||||
reactionIDs = append(reactionIDs, reaction.DocumentIDs()...)
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, reactionIDs)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if _, ok := seenDocs[doc.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenDocs[doc.ID] = struct{}{}
|
||||
stats.Documents++
|
||||
warmed, err := s.prewarmDocumentBlobs(ctx, doc)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Blobs += warmed
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmDocumentBlobs(ctx context.Context, doc domain.Document) (int, error) {
|
||||
if doc.ID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
warmed := 0
|
||||
ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d", doc.ID))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ok {
|
||||
warmed++
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if !thumb.Downloadable() {
|
||||
continue
|
||||
}
|
||||
ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ok {
|
||||
warmed++
|
||||
}
|
||||
}
|
||||
return warmed, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmLocationKey(ctx context.Context, locationKey string) (bool, error) {
|
||||
blob, ok := s.blobCache.get(locationKey)
|
||||
if !ok {
|
||||
var (
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
blob, found, err = s.media.GetFileBlob(ctx, locationKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
s.blobCache.put(locationKey, blob)
|
||||
}
|
||||
if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) {
|
||||
return false, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||||
s.byteCache.put(blob.ObjectKey, data)
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
2
internal/app/help/doc.go
Normal file
2
internal/app/help/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package help 提供 help.* RPC 背后的数据目录服务。
|
||||
package help
|
||||
76
internal/app/help/service.go
Normal file
76
internal/app/help/service.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const tdesktopClient = "tdesktop"
|
||||
const tdesktopDefaultAppConfig = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_in_chat_max":3}`
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
type Service struct {
|
||||
appConfigs store.AppConfigStore
|
||||
countries store.CountryStore
|
||||
}
|
||||
|
||||
// NewService 创建 help 服务。
|
||||
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service {
|
||||
return &Service{appConfigs: appConfigs, countries: countries}
|
||||
}
|
||||
|
||||
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。
|
||||
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
|
||||
if s == nil || s.appConfigs == nil {
|
||||
cfg := domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
|
||||
return cfg, hash == cfg.Hash, nil
|
||||
}
|
||||
cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient)
|
||||
if err != nil {
|
||||
return domain.AppConfig{}, false, err
|
||||
}
|
||||
if !found {
|
||||
cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
|
||||
}
|
||||
return cfg, hash != 0 && hash == cfg.Hash, nil
|
||||
}
|
||||
|
||||
// GetCountries 返回国家区号目录,hash 命中时返回 notModified。
|
||||
func (s *Service) GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) {
|
||||
if s == nil || s.countries == nil {
|
||||
list := defaultCountries()
|
||||
return list, hash != 0 && hash == list.Hash, nil
|
||||
}
|
||||
list, err := s.countries.ListCountries(ctx, langCode)
|
||||
if err != nil {
|
||||
return domain.CountriesList{}, false, err
|
||||
}
|
||||
if len(list.Countries) == 0 {
|
||||
list = defaultCountries()
|
||||
}
|
||||
return list, hash != 0 && hash == list.Hash, nil
|
||||
}
|
||||
|
||||
func defaultCountries() domain.CountriesList {
|
||||
return domain.CountriesList{
|
||||
Hash: 1,
|
||||
Countries: []domain.Country{
|
||||
{
|
||||
ISO2: "US",
|
||||
DefaultName: "United States",
|
||||
CountryCodes: []domain.CountryCode{
|
||||
{CountryCode: "1", Prefixes: []string{"1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ISO2: "CN",
|
||||
DefaultName: "China",
|
||||
CountryCodes: []domain.CountryCode{
|
||||
{CountryCode: "86", Prefixes: []string{"86"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
109
internal/app/langpack/parser.go
Normal file
109
internal/app/langpack/parser.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`)
|
||||
|
||||
// ParseTDesktopFile 解析 TDesktop .strings 文件为 domain 语言包。
|
||||
func ParseTDesktopFile(path string) (domain.LangPack, error) {
|
||||
pack, err := packFromFilename(path)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return domain.LangPack{}, fmt.Errorf("read tdesktop langpack %q: %w", path, err)
|
||||
}
|
||||
|
||||
plain := make([]domain.LangPackString, 0)
|
||||
plurals := make(map[string]*domain.LangPackString)
|
||||
pluralOrder := make([]string, 0)
|
||||
for _, match := range tdesktopStringRE.FindAllStringSubmatch(string(data), -1) {
|
||||
key := unquoteTDesktop(match[1])
|
||||
value := unquoteTDesktop(match[2])
|
||||
base, plural := splitPluralKey(key)
|
||||
if plural == "" {
|
||||
plain = append(plain, domain.LangPackString{Key: key, Value: value})
|
||||
continue
|
||||
}
|
||||
item, ok := plurals[base]
|
||||
if !ok {
|
||||
plurals[base] = &domain.LangPackString{Key: base, Pluralized: true}
|
||||
item = plurals[base]
|
||||
pluralOrder = append(pluralOrder, base)
|
||||
}
|
||||
setPluralValue(item, plural, value)
|
||||
}
|
||||
|
||||
pack.Strings = make([]domain.LangPackString, 0, len(plain)+len(pluralOrder))
|
||||
pack.Strings = append(pack.Strings, plain...)
|
||||
for _, key := range pluralOrder {
|
||||
pack.Strings = append(pack.Strings, *plurals[key])
|
||||
}
|
||||
return pack, nil
|
||||
}
|
||||
|
||||
func packFromFilename(path string) (domain.LangPack, error) {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
const prefix = "tdesktop_"
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path))
|
||||
}
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
idx := strings.LastIndex(rest, "_v")
|
||||
if idx <= 0 || idx+2 >= len(rest) {
|
||||
return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path))
|
||||
}
|
||||
version, err := strconv.Atoi(rest[idx+2:])
|
||||
if err != nil {
|
||||
return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", rest[idx+2:], err)
|
||||
}
|
||||
return domain.LangPack{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: rest[:idx],
|
||||
Version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func splitPluralKey(key string) (base, plural string) {
|
||||
for _, suffix := range []string{"#zero", "#one", "#two", "#few", "#many", "#other"} {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
return strings.TrimSuffix(key, suffix), strings.TrimPrefix(suffix, "#")
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
|
||||
func setPluralValue(item *domain.LangPackString, plural, value string) {
|
||||
switch plural {
|
||||
case "zero":
|
||||
item.ZeroValue = value
|
||||
case "one":
|
||||
item.OneValue = value
|
||||
case "two":
|
||||
item.TwoValue = value
|
||||
case "few":
|
||||
item.FewValue = value
|
||||
case "many":
|
||||
item.ManyValue = value
|
||||
case "other":
|
||||
item.OtherValue = value
|
||||
}
|
||||
}
|
||||
|
||||
func unquoteTDesktop(s string) string {
|
||||
v, err := strconv.Unquote(`"` + s + `"`)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return v
|
||||
}
|
||||
37
internal/app/langpack/parser_test.go
Normal file
37
internal/app/langpack/parser_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTDesktopFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tdesktop_en_v42.strings")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
"lng_plain" = "Plain value";
|
||||
"lng_escape" = "Line\nTwo";
|
||||
"lng_items#one" = "{count} item";
|
||||
"lng_items#other" = "{count} items";
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
pack, err := ParseTDesktopFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if pack.LangPack != "tdesktop" || pack.LangCode != "en" || pack.Version != 42 {
|
||||
t.Fatalf("pack meta = %+v", pack)
|
||||
}
|
||||
if len(pack.Strings) != 3 {
|
||||
t.Fatalf("strings count = %d, want 3", len(pack.Strings))
|
||||
}
|
||||
if got := pack.Strings[1].Value; got != "Line\nTwo" {
|
||||
t.Fatalf("escape value = %q", got)
|
||||
}
|
||||
plural := pack.Strings[2]
|
||||
if !plural.Pluralized || plural.Key != "lng_items" || plural.OneValue == "" || plural.OtherValue == "" {
|
||||
t.Fatalf("plural string = %+v", plural)
|
||||
}
|
||||
}
|
||||
56
internal/app/langpack/seed.go
Normal file
56
internal/app/langpack/seed.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SeedDirectory 将导出的 .strings 文件导入 LangPackStore。
|
||||
// root 可直接指向 data/langpack,也可指向包含 .strings 的具体平台目录。
|
||||
func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
|
||||
if s == nil || s.packs == nil || root == "" {
|
||||
return 0, nil
|
||||
}
|
||||
dir := filepath.Clean(root)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("stat langpack seed dir: %w", err)
|
||||
}
|
||||
tdesktopDir := filepath.Join(dir, "tdesktop")
|
||||
if info, err := os.Stat(tdesktopDir); err == nil && info.IsDir() {
|
||||
dir = tdesktopDir
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read langpack seed dir: %w", err)
|
||||
}
|
||||
seeded := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
|
||||
continue
|
||||
}
|
||||
pack, err := ParseTDesktopFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return seeded, err
|
||||
}
|
||||
existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version)
|
||||
if err != nil {
|
||||
return seeded, err
|
||||
}
|
||||
if existing.Version >= pack.Version {
|
||||
continue
|
||||
}
|
||||
if err := s.packs.UpsertPack(ctx, pack); err != nil {
|
||||
return seeded, err
|
||||
}
|
||||
seeded += len(pack.Strings)
|
||||
}
|
||||
return seeded, nil
|
||||
}
|
||||
53
internal/app/langpack/service.go
Normal file
53
internal/app/langpack/service.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package langpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供客户端语言包查询。
|
||||
type Service struct {
|
||||
packs store.LangPackStore
|
||||
}
|
||||
|
||||
// NewService 创建 langpack 服务。
|
||||
func NewService(packs store.LangPackStore) *Service {
|
||||
return &Service{packs: packs}
|
||||
}
|
||||
|
||||
// GetLangPack 返回完整语言包。
|
||||
func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
|
||||
return s.GetDifference(ctx, langPack, langCode, 0)
|
||||
}
|
||||
|
||||
// GetDifference 返回从 fromVersion 到当前版本的语言包差异。
|
||||
func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
||||
if s == nil || s.packs == nil {
|
||||
return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil
|
||||
}
|
||||
return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion)
|
||||
}
|
||||
|
||||
// GetStrings 返回指定 key 的语言包字符串。
|
||||
func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
|
||||
if s == nil || s.packs == nil {
|
||||
return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil
|
||||
}
|
||||
return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys)
|
||||
}
|
||||
|
||||
func normalizePack(langPack string) string {
|
||||
if langPack == "" {
|
||||
return "tdesktop"
|
||||
}
|
||||
return langPack
|
||||
}
|
||||
|
||||
func normalizeCode(langCode string) string {
|
||||
if langCode == "" {
|
||||
return "en"
|
||||
}
|
||||
return langCode
|
||||
}
|
||||
74
internal/app/maintenance/retention.go
Normal file
74
internal/app/maintenance/retention.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DispatchOutboxRetentionStore 清理彻底失败(已放弃重试)的 outbox 死任务。
|
||||
type DispatchOutboxRetentionStore interface {
|
||||
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
|
||||
}
|
||||
|
||||
// RetentionWorker 周期性回收存储中的死数据。
|
||||
//
|
||||
// 注意:本 worker 刻意不清理 user_update_events —— pts log 永久保留。原因:TDesktop 不支持
|
||||
// 账号级 updates.differenceTooLong(api_updates.cpp 收到该响应只打一行日志,且漏掉
|
||||
// setRequesting(false),会永久锁死整个 update 引擎),服务端因此无法让"落后超过保留期"的
|
||||
// 客户端整库重置;一旦裁剪 events,落后客户端的 getDifference 会拿到不完整的事件链而静默
|
||||
// 丢消息。详见 docs/performance-audit.md 与 docs/compatibility-matrix.md。user_update_events
|
||||
// 长期膨胀作为已知 todo。
|
||||
type RetentionWorker struct {
|
||||
outbox DispatchOutboxRetentionStore
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if retention <= 0 {
|
||||
retention = 168 * time.Hour
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = 10000
|
||||
}
|
||||
return &RetentionWorker{
|
||||
outbox: outbox,
|
||||
logger: logger,
|
||||
retention: retention,
|
||||
interval: interval,
|
||||
batch: batch,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) Run(ctx context.Context) {
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) runOnce(ctx context.Context) {
|
||||
outboxDeleted, err := w.outbox.DeleteFailed(ctx, w.retention, w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("清理 failed dispatch_outbox 失败", zap.Error(err))
|
||||
} else if outboxDeleted > 0 {
|
||||
w.logger.Info("清理 failed dispatch_outbox 完成", zap.Int("deleted", outboxDeleted))
|
||||
}
|
||||
}
|
||||
3
internal/app/messages/doc.go
Normal file
3
internal/app/messages/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package messages 是消息应用服务:发消息、编辑、删除、已读、历史记录。
|
||||
// 第一阶段先实现官方系统会话所需的历史、搜索和已读最小闭环;第二阶段扩展私聊发送与推送。
|
||||
package messages
|
||||
180
internal/app/messages/service.go
Normal file
180
internal/app/messages/service.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供消息历史、搜索与已读业务。
|
||||
type Service struct {
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
}
|
||||
|
||||
// NewService 创建 messages 服务。
|
||||
func NewService(messages store.MessageStore, dialogs store.DialogStore) *Service {
|
||||
return &Service{messages: messages, dialogs: dialogs}
|
||||
}
|
||||
|
||||
// SendPrivateText 发送一条私聊文本消息。
|
||||
func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.SendPrivateTextResult{}, nil
|
||||
}
|
||||
if req.SenderUserID == 0 {
|
||||
req.SenderUserID = userID
|
||||
}
|
||||
return s.messages.SendPrivateText(ctx, req)
|
||||
}
|
||||
|
||||
// ForwardPrivateMessages 转发当前账号可见的私聊文本消息。
|
||||
func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.ForwardPrivateMessagesResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.ForwardPrivateMessages(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessages returns exact owner-visible message boxes in request order.
|
||||
func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 || len(ids) == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
return s.messages.GetByIDs(ctx, userID, ids)
|
||||
}
|
||||
|
||||
// GetHistory 返回当前账号某个 peer 的历史消息。
|
||||
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
return s.list(ctx, userID, filter)
|
||||
}
|
||||
|
||||
// Search 返回当前账号消息搜索结果。Query 为空时等价于历史列表过滤。
|
||||
func (s *Service) Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
return s.list(ctx, userID, filter)
|
||||
}
|
||||
|
||||
// ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。
|
||||
func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return domain.ReadHistoryResult{Peer: req.Peer, MaxID: req.MaxID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if s.messages != nil {
|
||||
return s.messages.ReadHistory(ctx, req)
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
return s.dialogs.MarkRead(ctx, userID, req.Peer, req.MaxID)
|
||||
}
|
||||
return domain.ReadHistoryResult{OwnerUserID: userID, Peer: req.Peer, MaxID: req.MaxID}, nil
|
||||
}
|
||||
|
||||
// ReadMessageContents checks exact owner-visible private message IDs for content-read sync.
|
||||
func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) {
|
||||
res := domain.ReadMessageContentsResult{OwnerUserID: userID}
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return res, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID || len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
return s.messages.ReadMessageContents(ctx, req)
|
||||
}
|
||||
|
||||
// GetOutboxReadDate 返回当前账号某条 outgoing 私聊消息被对端读到的时间。
|
||||
func (s *Service) GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return 0, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.GetOutboxReadDate(ctx, req)
|
||||
}
|
||||
|
||||
// SetMessageReactions replaces the current user's reactions on a visible private message.
|
||||
func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return s.messages.SetMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessageReactions returns reaction summaries for visible private messages.
|
||||
func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PrivateMessageReactionsResult{}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
return s.messages.GetMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// EditMessage 编辑当前账号发出的私聊文本消息。
|
||||
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.EditMessageResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.EditMessage(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteMessages 删除当前账号视角下的一组消息;revoke 时同步删除对端私聊盒子。
|
||||
func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.DeleteMessagesResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.DeleteMessages(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteHistory 清空当前账号与某个 peer 的历史;revoke 时同步删除对端私聊盒子。
|
||||
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.DeleteMessagesResult{OwnerUserID: userID}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.DeleteHistory(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
return s.messages.ListByUser(ctx, userID, filter)
|
||||
}
|
||||
154
internal/app/updates/contiguous_test.go
Normal file
154
internal/app/updates/contiguous_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts int) {
|
||||
t.Helper()
|
||||
if err := events.Append(context.Background(), userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventNewMessage,
|
||||
Pts: pts,
|
||||
PtsCount: 1,
|
||||
Date: 1700000000 + pts,
|
||||
Message: domain.Message{
|
||||
ID: pts,
|
||||
OwnerUserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed event pts=%d: %v", pts, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDifferenceStopsAtHole:getDifference 只返回从 from 起连续的事件,遇在途空洞即截断,
|
||||
// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。
|
||||
func TestGetDifferenceStopsAtHole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
userID := int64(1000000001)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
// pts 1,2,3 已提交,4 在途(缺),5,6 已提交 → 连续只到 3。
|
||||
for _, p := range []int{1, 2, 3, 5, 6} {
|
||||
seedEvent(t, events, userID, p)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 3 || diff.State.Pts != 3 {
|
||||
t.Fatalf("diff = %d events, state.pts %d; want 3 连续事件、止于空洞(pts=3)", len(diff.Events), diff.State.Pts)
|
||||
}
|
||||
if diff.Partial {
|
||||
t.Fatalf("Partial=true,want false(被空洞而非 limit 截断)")
|
||||
}
|
||||
|
||||
// 补上 pts=4(在途事务提交)后,从 3 继续应拿到 4,5,6。
|
||||
seedEvent(t, events, userID, 4)
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference after fill: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 3 || diff.State.Pts != 6 {
|
||||
t.Fatalf("补洞后 diff = %d events, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDifferenceSliceOnLimit:连续事件填满 limit 时返回 Partial(=differenceSlice),
|
||||
// State.Pts 为中间态;客户端据此续拉,最终一页 Partial=false。
|
||||
func TestGetDifferenceSliceOnLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
userID := int64(1000000002)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
total := getDifferenceLimit + 25
|
||||
for p := 1; p <= total; p++ {
|
||||
seedEvent(t, events, userID, p)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != getDifferenceLimit || !diff.Partial || diff.State.Pts != getDifferenceLimit {
|
||||
t.Fatalf("第一页 = %d events partial %v state.pts %d; want %d/true/%d",
|
||||
len(diff.Events), diff.Partial, diff.State.Pts, getDifferenceLimit, getDifferenceLimit)
|
||||
}
|
||||
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: getDifferenceLimit})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference page2: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 25 || diff.Partial || diff.State.Pts != total {
|
||||
t.Fatalf("第二页 = %d events partial %v state.pts %d; want 25/false/%d",
|
||||
len(diff.Events), diff.Partial, diff.State.Pts, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetStateReportsContiguousNotMax:getState 报告最大连续 pts,而非最大已提交 pts,
|
||||
// 避免首次登录基线越过在途空洞而丢消息。
|
||||
func TestGetStateReportsContiguousNotMax(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
userID := int64(1000000003)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
|
||||
for _, p := range []int{1, 2, 3, 5, 6} { // 4 在途空洞,最大已提交=6
|
||||
seedEvent(t, events, userID, p)
|
||||
}
|
||||
st, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if st.Pts != 3 {
|
||||
t.Fatalf("GetState.Pts=%d, want 3(最大连续,而非最大已提交 6)", st.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 7
|
||||
userID := int64(1000000004)
|
||||
events := memory.NewUpdateEventStore()
|
||||
states := memory.NewUpdateStateStore()
|
||||
svc := NewService(states, events)
|
||||
|
||||
seedEvent(t, events, userID, 1)
|
||||
if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil {
|
||||
t.Fatalf("Save state: %v", err)
|
||||
}
|
||||
seedEvent(t, events, userID, 2)
|
||||
|
||||
st, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if st.Pts != 1 {
|
||||
t.Fatalf("GetState.Pts=%d, want existing confirmed pts=1", st.Pts)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, st)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 1 || diff.Events[0].Pts != 2 || diff.State.Pts != 2 {
|
||||
t.Fatalf("diff = %+v, want one event at pts=2 and confirmed state pts=2", diff)
|
||||
}
|
||||
st, err = svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState after difference: %v", err)
|
||||
}
|
||||
if st.Pts != 2 {
|
||||
t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts)
|
||||
}
|
||||
}
|
||||
3
internal/app/updates/doc.go
Normal file
3
internal/app/updates/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package updates 是更新状态机与投递:user 级 pts/qts/seq/date、离线差量(updates.getDifference)、
|
||||
// 在线推送。第一阶段持久化 auth_key 维度的初始空状态,消息事件队列留第二阶段。
|
||||
package updates
|
||||
465
internal/app/updates/service.go
Normal file
465
internal/app/updates/service.go
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供 update 状态查询。
|
||||
type Service struct {
|
||||
states store.UpdateStateStore
|
||||
events store.UpdateEventStore
|
||||
pts store.PtsAllocator
|
||||
}
|
||||
|
||||
type dispatchingEventAppender interface {
|
||||
AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error
|
||||
}
|
||||
|
||||
// ServiceOption 调整 updates 服务的运行时依赖。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。
|
||||
func WithPtsAllocator(pts store.PtsAllocator) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.pts = pts
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 updates 服务。
|
||||
func NewService(states store.UpdateStateStore, events store.UpdateEventStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{states: states}
|
||||
s.events = events
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// UsesReliableDispatch 表示设置类 update 已写入 transactional outbox,由 outbox worker 投递在线 session。
|
||||
func (s *Service) UsesReliableDispatch() bool {
|
||||
if s == nil || s.events == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := s.events.(dispatchingEventAppender)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetState 返回当前 auth_key + user 维度已确认的 update 状态。
|
||||
// user_update_events 是账号级 durable log;auth_key 维度只保存设备已经通过
|
||||
// getDifference 确认到的状态,不能在 getState 中直接推进到账号最新水位。
|
||||
func (s *Service) GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
|
||||
now := int(time.Now().Unix())
|
||||
// 私聊阶段不维护账号级 seq:对外 UpdateState.Seq 恒为 0,客户端仅靠 pts 同步、
|
||||
// 跳过 seq gap 检测(推送信封 seq 同样恒 0)。
|
||||
if s.states == nil {
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return domain.UpdateState{Pts: current, Date: now, Seq: 0}, nil
|
||||
}
|
||||
st, found, err := s.states.Get(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
if found {
|
||||
st.Seq = 0
|
||||
if st.Date == 0 {
|
||||
st.Date = now
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
st = domain.UpdateState{Pts: current, Date: now, Seq: 0}
|
||||
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// CurrentState 返回账号当前最大连续 update 状态,不修改任何设备已确认水位。
|
||||
func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
|
||||
return s.currentState(ctx, userID)
|
||||
}
|
||||
|
||||
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
|
||||
const getDifferenceLimit = 100
|
||||
|
||||
// GetDifference 返回当前 user 从 from 状态之后的增量事件。
|
||||
//
|
||||
// 对齐 MTProto:只返回从 from.Pts 起「连续」的事件(遇空洞即截断),State.Pts 取最后连续值,
|
||||
// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。
|
||||
// 连续事件填满 limit 时置 Partial(映射 differenceSlice),客户端据返回 State 继续翻页。
|
||||
func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) {
|
||||
st, err := s.currentState(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
if s.events == nil || from.Pts >= st.Pts {
|
||||
if from.Date != 0 {
|
||||
st.Date = from.Date
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
return domain.UpdateDifference{State: st}, nil
|
||||
}
|
||||
events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit)
|
||||
if err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
contiguous := contiguousPrefix(events, from.Pts)
|
||||
last := from.Pts
|
||||
if len(contiguous) > 0 {
|
||||
last = contiguous[len(contiguous)-1].Pts
|
||||
}
|
||||
out := st
|
||||
out.Pts = last
|
||||
out.Seq = 0 // seq 恒 0,见 GetState 注释
|
||||
if len(contiguous) > 0 {
|
||||
out.Date = contiguous[len(contiguous)-1].Date
|
||||
}
|
||||
if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil {
|
||||
return domain.UpdateDifference{}, err
|
||||
}
|
||||
return domain.UpdateDifference{
|
||||
State: out,
|
||||
Events: contiguous,
|
||||
Partial: len(contiguous) == getDifferenceLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, err
|
||||
}
|
||||
return domain.UpdateState{
|
||||
Pts: current,
|
||||
Date: int(time.Now().Unix()),
|
||||
Seq: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState) error {
|
||||
if s.states == nil {
|
||||
return nil
|
||||
}
|
||||
st.Seq = 0
|
||||
return s.states.Save(ctx, authKeyID, userID, st)
|
||||
}
|
||||
|
||||
// contiguousPrefix 返回从 from 起 pts 严格连续(from+1, from+2, ...)的事件前缀。
|
||||
// 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。
|
||||
func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
sorted := make([]domain.UpdateEvent, len(events))
|
||||
copy(sorted, events)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Pts < sorted[j].Pts })
|
||||
cursor := from
|
||||
out := make([]domain.UpdateEvent, 0, len(sorted))
|
||||
for _, event := range sorted {
|
||||
ptsCount := event.PtsCount
|
||||
if ptsCount <= 0 {
|
||||
ptsCount = 1
|
||||
}
|
||||
if event.Pts != cursor+ptsCount {
|
||||
break
|
||||
}
|
||||
out = append(out, event)
|
||||
cursor = event.Pts
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClearAuthKey 清理某 auth_key 的设备状态。
|
||||
// user_update_events 是账号级 durable log,不能因设备退出登录被删除。
|
||||
func (s *Service) ClearAuthKey(ctx context.Context, authKeyID [8]byte) error {
|
||||
if s.states != nil {
|
||||
if err := s.states.DeleteAuthKey(ctx, authKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordNewMessage 推进 update 状态并追加一条 new_message 事件。
|
||||
func (s *Service) RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
}
|
||||
date := msg.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventNewMessage,
|
||||
Date: date,
|
||||
Message: msg,
|
||||
PtsCount: 1,
|
||||
}, false, 0)
|
||||
}
|
||||
|
||||
// RecordMessageReactions records a durable marker for message reaction changes.
|
||||
//
|
||||
// updateMessageReactions has no pts fields in Layer 225, but TDesktop still
|
||||
// needs getDifference to advance account pts and carry the latest reaction
|
||||
// aggregate for offline devices.
|
||||
func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = msg.OwnerUserID
|
||||
}
|
||||
date := msg.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventMessageReactions,
|
||||
Date: date,
|
||||
Message: msg,
|
||||
Peer: msg.Peer,
|
||||
PtsCount: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordReadHistory 推进 update 状态并追加一条 read_history_inbox 事件。
|
||||
func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if userID == 0 {
|
||||
userID = read.OwnerUserID
|
||||
}
|
||||
date := int(time.Now().Unix())
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventReadHistoryInbox,
|
||||
Date: date,
|
||||
Peer: read.Peer,
|
||||
MaxID: read.MaxID,
|
||||
StillUnreadCount: read.StillUnreadCount,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordContactsReset 记录通讯录视角变化,供离线设备通过 updates.getDifference 触发重拉。
|
||||
func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventContactsReset,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogPinned 记录单个会话置顶状态变化。
|
||||
func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogPinned,
|
||||
Peer: peer,
|
||||
Bool: pinned,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPinnedDialogs 记录置顶会话顺序变化,并把新顺序持久化给 getDifference/outbox。
|
||||
func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPinnedDialogs,
|
||||
Peers: append([]domain.Peer(nil), order...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogUnreadMark 记录手动未读标记变化。
|
||||
func (s *Service) RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogUnreadMark,
|
||||
Peer: peer,
|
||||
Bool: unread,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelViewForumAsMessages records a per-account forum presentation state change.
|
||||
func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventChannelViewForum,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
Bool: enabled,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordPeerSettings 记录 peer settings 变化。
|
||||
func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPeerSettings,
|
||||
Peer: peer,
|
||||
Settings: settings,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFilter 记录单个 filter 的创建、更新或删除;folder 为 nil 表示删除。
|
||||
func (s *Service) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
var copyFolder *domain.DialogFolder
|
||||
if folder != nil {
|
||||
f := *folder
|
||||
copyFolder = &f
|
||||
}
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogFilter,
|
||||
FilterID: folderID,
|
||||
DialogFilter: copyFolder,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFilterOrder 记录 filter 顺序变化。
|
||||
func (s *Service) RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogFilterOrder,
|
||||
FilterOrder: append([]int(nil), order...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordDialogFiltersReload 通知其他设备重新拉取 filter 列表。
|
||||
func (s *Service) RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventDialogFilters,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordFolderPeers 记录归档/还原会话的 folder_id 变化。
|
||||
func (s *Service) RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventFolderPeers,
|
||||
FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...),
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
// RecordChannelAvailableMessages records a local channel history clear for multi-device sync.
|
||||
func (s *Service) RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventChannelAvailable,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
MaxID: availableMinID,
|
||||
PtsCount: 1,
|
||||
}, true, excludeSessionID)
|
||||
}
|
||||
|
||||
func (s *Service) recordEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEventCore(ctx, authKeyID, userID, event, dispatch, excludeSessionID, true)
|
||||
}
|
||||
|
||||
func (s *Service) recordEventWithoutState(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
return s.recordEventCore(ctx, [8]byte{}, userID, event, false, 0, false)
|
||||
}
|
||||
|
||||
func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64, saveState bool) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
date := event.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
if event.PtsCount == 0 {
|
||||
event.PtsCount = 1
|
||||
}
|
||||
pts, err := s.nextPtsN(ctx, userID, event.PtsCount)
|
||||
if err != nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
st := domain.UpdateState{Pts: pts, Date: date, Seq: 0}
|
||||
event.UserID = userID
|
||||
event.Pts = st.Pts
|
||||
event.Date = date
|
||||
if s.events != nil {
|
||||
var err error
|
||||
if dispatch {
|
||||
if appender, ok := s.events.(dispatchingEventAppender); ok {
|
||||
err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID)
|
||||
} else {
|
||||
err = s.events.Append(ctx, userID, event)
|
||||
}
|
||||
} else {
|
||||
err = s.events.Append(ctx, userID, event)
|
||||
}
|
||||
if err != nil {
|
||||
if !dispatch {
|
||||
_ = s.events.Append(ctx, userID, domain.UpdateEvent{
|
||||
UserID: userID,
|
||||
Type: domain.UpdateEventNoop,
|
||||
Pts: pts,
|
||||
PtsCount: event.PtsCount,
|
||||
Date: date,
|
||||
})
|
||||
}
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
}
|
||||
if saveState && s.states != nil {
|
||||
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, err
|
||||
}
|
||||
}
|
||||
return event, st, nil
|
||||
}
|
||||
|
||||
// currentPts 供 GetState 报告「当前 pts」。对齐 MTProto:报告最大连续已提交 pts,
|
||||
// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件,
|
||||
// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。
|
||||
func (s *Service) currentPts(ctx context.Context, userID int64) (int, error) {
|
||||
if s.events != nil {
|
||||
return s.events.MaxContiguousPts(ctx, userID)
|
||||
}
|
||||
if s.pts != nil {
|
||||
return s.pts.CurrentPts(ctx, userID)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextPts(ctx context.Context, userID int64) (int, error) {
|
||||
if s.pts != nil {
|
||||
return s.pts.NextPts(ctx, userID)
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current + 1, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextPtsN(ctx context.Context, userID int64, count int) (int, error) {
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
if count == 1 {
|
||||
return s.nextPts(ctx, userID)
|
||||
}
|
||||
if s.pts != nil {
|
||||
if ranges, ok := s.pts.(store.PtsRangeAllocator); ok {
|
||||
return ranges.NextPtsN(ctx, userID, count)
|
||||
}
|
||||
var pts int
|
||||
var err error
|
||||
for i := 0; i < count; i++ {
|
||||
pts, err = s.pts.NextPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
current, err := s.currentPts(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current + count, nil
|
||||
}
|
||||
252
internal/app/updates/service_test.go
Normal file
252
internal/app/updates/service_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestRecordNewMessageFeedsGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 1
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
msg := domain.Message{
|
||||
ID: 10,
|
||||
OwnerUserID: 1000000001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: 1700000000,
|
||||
Body: "Login code: 12345",
|
||||
}
|
||||
|
||||
event, state, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordNewMessage: %v", err)
|
||||
}
|
||||
if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 || state.Seq != 0 {
|
||||
t.Fatalf("event/state = %+v / %+v, want first pts event with seq=0", event, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Message.ID != msg.ID {
|
||||
t.Fatalf("diff = %+v, want recorded login message event and state %+v", diff, state)
|
||||
}
|
||||
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, state)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference current: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 0 || diff.State != state {
|
||||
t.Fatalf("current diff = %+v, want empty events and same state", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReadHistoryFeedsGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 2
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
|
||||
|
||||
ownerUserID := int64(1000000001)
|
||||
event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{
|
||||
OwnerUserID: ownerUserID,
|
||||
Peer: peer,
|
||||
MaxID: 10,
|
||||
Changed: true,
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordReadHistory: %v", err)
|
||||
}
|
||||
if event.Type != domain.UpdateEventReadHistoryInbox || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 {
|
||||
t.Fatalf("event/state = %+v / %+v, want read history event with first pts", event, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].MaxID != 10 {
|
||||
t.Fatalf("diff = %+v, want recorded read history event and state %+v", diff, state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 3
|
||||
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
|
||||
ownerUserID := int64(1000000001)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||
|
||||
if _, _, err := svc.RecordContactsReset(ctx, authKeyID, ownerUserID, 0); err != nil {
|
||||
t.Fatalf("RecordContactsReset: %v", err)
|
||||
}
|
||||
if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0); err != nil {
|
||||
t.Fatalf("RecordDialogPinned: %v", err)
|
||||
}
|
||||
order := []domain.Peer{peer}
|
||||
if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, order, 0); err != nil {
|
||||
t.Fatalf("RecordPinnedDialogs: %v", err)
|
||||
}
|
||||
if _, _, err := svc.RecordDialogUnreadMark(ctx, authKeyID, ownerUserID, peer, false, 0); err != nil {
|
||||
t.Fatalf("RecordDialogUnreadMark: %v", err)
|
||||
}
|
||||
settings := domain.PeerSettings{ShareContact: true}
|
||||
stateEvent, state, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPeerSettings: %v", err)
|
||||
}
|
||||
if stateEvent.Pts != 5 || state.Pts != 5 {
|
||||
t.Fatalf("last event/state = %+v / %+v, want pts=5", stateEvent, state)
|
||||
}
|
||||
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 5 || len(diff.Events) != 5 {
|
||||
t.Fatalf("diff = %+v, want five settings events", diff)
|
||||
}
|
||||
wantTypes := []domain.UpdateEventType{
|
||||
domain.UpdateEventContactsReset,
|
||||
domain.UpdateEventDialogPinned,
|
||||
domain.UpdateEventPinnedDialogs,
|
||||
domain.UpdateEventDialogUnreadMark,
|
||||
domain.UpdateEventPeerSettings,
|
||||
}
|
||||
for i, typ := range wantTypes {
|
||||
if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 {
|
||||
t.Fatalf("event[%d] = %+v, want type=%s pts=%d pts_count=1", i, diff.Events[i], typ, i+1)
|
||||
}
|
||||
}
|
||||
if diff.Events[1].Peer != peer || !diff.Events[1].Bool {
|
||||
t.Fatalf("dialog pinned event = %+v, want peer and pinned=true", diff.Events[1])
|
||||
}
|
||||
if diff.Events[3].Peer != peer || diff.Events[3].Bool {
|
||||
t.Fatalf("unread mark event = %+v, want peer and unread=false", diff.Events[3])
|
||||
}
|
||||
if len(diff.Events[2].Peers) != 1 || diff.Events[2].Peers[0] != peer {
|
||||
t.Fatalf("pinned dialogs event = %+v, want order peer", diff.Events[2])
|
||||
}
|
||||
if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact {
|
||||
t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 4
|
||||
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||
|
||||
event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDialogPinned: %v", err)
|
||||
}
|
||||
if event.Pts != 1 || state.Pts != 1 {
|
||||
t.Fatalf("event/state = %+v / %+v, want first pts", event, state)
|
||||
}
|
||||
if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 42 || events.event.Type != domain.UpdateEventDialogPinned || events.event.Peer != peer {
|
||||
t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want dialog_pinned outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAuthKeyDropsStateAndEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 8
|
||||
states := memory.NewUpdateStateStore()
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(states, events)
|
||||
msg := domain.Message{
|
||||
ID: 1,
|
||||
OwnerUserID: 1000000001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: 1700000000,
|
||||
}
|
||||
if _, _, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg); err != nil {
|
||||
t.Fatalf("RecordNewMessage: %v", err)
|
||||
}
|
||||
if err := svc.ClearAuthKey(ctx, authKeyID); err != nil {
|
||||
t.Fatalf("ClearAuthKey: %v", err)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 1 || len(diff.Events) != 1 {
|
||||
t.Fatalf("difference after clear = %+v, want durable user events to remain", diff)
|
||||
}
|
||||
diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID+1, domain.UpdateState{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference other user: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 0 || len(diff.Events) != 0 {
|
||||
t.Fatalf("difference for other user after clear = %+v, want no cross-account events", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var authKeyID [8]byte
|
||||
authKeyID[0] = 9
|
||||
userID := int64(1000000001)
|
||||
events := memory.NewUpdateEventStore()
|
||||
svc := NewService(memory.NewUpdateStateStore(), events)
|
||||
for _, event := range []domain.UpdateEvent{
|
||||
{UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID}},
|
||||
{UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 2, PtsCount: 1, Date: 1700000002, Message: domain.Message{ID: 2, OwnerUserID: userID}},
|
||||
{UserID: userID, Type: domain.UpdateEventDeleteMessages, Pts: 4, PtsCount: 2, Date: 1700000003, MessageIDs: []int{1, 2}},
|
||||
} {
|
||||
if err := events.Append(ctx, userID, event); err != nil {
|
||||
t.Fatalf("append event pts=%d: %v", event.Pts, err)
|
||||
}
|
||||
}
|
||||
|
||||
state, err := svc.GetState(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if state.Pts != 4 {
|
||||
t.Fatalf("state = %+v, want contiguous pts=4 across delete range", state)
|
||||
}
|
||||
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if diff.State.Pts != 4 || len(diff.Events) != 1 {
|
||||
t.Fatalf("diff = %+v, want one delete event ending at pts=4", diff)
|
||||
}
|
||||
got := diff.Events[0]
|
||||
if got.Type != domain.UpdateEventDeleteMessages || got.Pts != 4 || got.PtsCount != 2 || len(got.MessageIDs) != 2 {
|
||||
t.Fatalf("delete event = %+v, want pts=4 pts_count=2 ids", got)
|
||||
}
|
||||
}
|
||||
|
||||
type captureDispatchAppender struct {
|
||||
*memory.UpdateEventStore
|
||||
dispatched bool
|
||||
userID int64
|
||||
event domain.UpdateEvent
|
||||
excludeAuthKeyID [8]byte
|
||||
excludeSessionID int64
|
||||
}
|
||||
|
||||
func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
|
||||
s.dispatched = true
|
||||
s.userID = userID
|
||||
s.event = event
|
||||
s.excludeAuthKeyID = excludeAuthKeyID
|
||||
s.excludeSessionID = excludeSessionID
|
||||
return s.UpdateEventStore.Append(ctx, userID, event)
|
||||
}
|
||||
3
internal/app/users/doc.go
Normal file
3
internal/app/users/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package users 是用户应用服务:用户资料、用户名、头像、在线状态。
|
||||
// 第一阶段先支持 users.getUsers 返回自身。
|
||||
package users
|
||||
313
internal/app/users/service.go
Normal file
313
internal/app/users/service.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// ErrNotAuthorized 表示当前 auth_key 尚未登录。
|
||||
var ErrNotAuthorized = errors.New("not authorized")
|
||||
|
||||
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
||||
type ProfilePhotoProvider interface {
|
||||
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
}
|
||||
|
||||
// Service 提供用户查询。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
photos ProfilePhotoProvider
|
||||
}
|
||||
|
||||
// Option 调整用户服务可选依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithPhotoProvider 注入头像富化能力(缺省则用户不带头像)。
|
||||
func WithPhotoProvider(p ProfilePhotoProvider) Option {
|
||||
return func(s *Service) { s.photos = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
maxProfileNameRunes = 64
|
||||
maxProfileAboutRunes = 70
|
||||
maxBatchUsers = 1000
|
||||
)
|
||||
|
||||
// NewService 创建用户服务。
|
||||
func NewService(users store.UserStore, opts ...Option) *Service {
|
||||
s := &Service{users: users}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// loadSelf 加载当前用户但不富化头像(供内部校验路径使用,避免无谓的头像查询)。
|
||||
func (s *Service) loadSelf(ctx context.Context, userID int64) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Self 返回当前登录的用户(带头像)。未登录返回 ErrNotAuthorized。
|
||||
func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) {
|
||||
u, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), nil
|
||||
}
|
||||
|
||||
// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。
|
||||
func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) {
|
||||
if currentUserID == 0 {
|
||||
return domain.User{}, false, ErrNotAuthorized
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return u, false, nil
|
||||
}
|
||||
return s.enrichOne(ctx, u), true, nil
|
||||
}
|
||||
|
||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||
if currentUserID == 0 {
|
||||
return nil, ErrNotAuthorized
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= maxBatchUsers {
|
||||
break
|
||||
}
|
||||
}
|
||||
users, err := s.users.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.enrich(ctx, users), nil
|
||||
}
|
||||
|
||||
// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。
|
||||
func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User {
|
||||
if s.photos == nil || len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID != 0 {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
}
|
||||
refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return users
|
||||
}
|
||||
for i := range users {
|
||||
if ref, ok := refs[users[i].ID]; ok {
|
||||
users[i].PhotoID = ref.PhotoID
|
||||
users[i].PhotoDCID = ref.DCID
|
||||
users[i].PhotoStripped = ref.Stripped
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User {
|
||||
enriched := s.enrich(ctx, []domain.User{u})
|
||||
return enriched[0]
|
||||
}
|
||||
|
||||
// CheckUsername 校验当前用户是否可以占用 username。
|
||||
func (s *Service) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !found || u.ID == self.ID, nil
|
||||
}
|
||||
|
||||
// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。
|
||||
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if username != "" {
|
||||
if !validUsername(username) {
|
||||
return domain.User{}, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if found && u.ID != self.ID {
|
||||
return domain.User{}, domain.ErrUsernameOccupied
|
||||
}
|
||||
}
|
||||
if self.Username == username {
|
||||
return self, nil
|
||||
}
|
||||
u, err := s.users.UpdateUsername(ctx, self.ID, username)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
firstName := self.FirstName
|
||||
lastName := self.LastName
|
||||
about := self.About
|
||||
if update.HasFirstName {
|
||||
firstName = strings.TrimSpace(update.FirstName)
|
||||
}
|
||||
if update.HasLastName {
|
||||
lastName = strings.TrimSpace(update.LastName)
|
||||
}
|
||||
if update.HasAbout {
|
||||
about = strings.TrimSpace(update.About)
|
||||
}
|
||||
if firstName == "" || utf8.RuneCountInString(firstName) > maxProfileNameRunes || utf8.RuneCountInString(lastName) > maxProfileNameRunes {
|
||||
return domain.User{}, domain.ErrFirstNameInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(about) > maxProfileAboutRunes {
|
||||
return domain.User{}, domain.ErrAboutTooLong
|
||||
}
|
||||
if firstName == self.FirstName && lastName == self.LastName && about == self.About {
|
||||
return self, nil
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about)
|
||||
}
|
||||
|
||||
// UpdateLastSeen records the latest visible account activity time.
|
||||
func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
|
||||
if userID == 0 {
|
||||
return ErrNotAuthorized
|
||||
}
|
||||
if lastSeenAt <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.users.UpdateLastSeen(ctx, userID, lastSeenAt)
|
||||
}
|
||||
|
||||
// ResolveUsername 解析 username 到用户;调用方必须已登录。
|
||||
func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
if err != nil || !found {
|
||||
return u, found, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), true, nil
|
||||
}
|
||||
|
||||
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
|
||||
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
if phone == "" {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
u, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil || !found {
|
||||
return u, found, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), true, nil
|
||||
}
|
||||
|
||||
func normalizeUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
return strings.TrimSpace(username)
|
||||
}
|
||||
|
||||
func validUsername(username string) bool {
|
||||
if len(username) < minUsernameLen || len(username) > maxUsernameLen {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(username); i++ {
|
||||
c := username[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c >= '0' && c <= '9':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
case c == '_':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if phone == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
130
internal/app/users/service_test.go
Normal file
130
internal/app/users/service_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceUsernameLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
other, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
svc := NewService(store)
|
||||
|
||||
if ok, err := svc.CheckUsername(ctx, owner.ID, "123bad"); err == nil || ok || !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("CheckUsername invalid = ok %v err %v, want username invalid", ok, err)
|
||||
}
|
||||
if ok, err := svc.CheckUsername(ctx, owner.ID, "taken_name"); err != nil || ok {
|
||||
t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err)
|
||||
}
|
||||
if ok, err := svc.CheckUsername(ctx, owner.ID, "owner_name"); err != nil || !ok {
|
||||
t.Fatalf("CheckUsername available = ok %v err %v, want true/nil", ok, err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateUsername(ctx, owner.ID, "@Owner_Name")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsername: %v", err)
|
||||
}
|
||||
if updated.Username != "Owner_Name" {
|
||||
t.Fatalf("updated username = %q, want Owner_Name", updated.Username)
|
||||
}
|
||||
resolved, found, err := svc.ResolveUsername(ctx, other.ID, "owner_name")
|
||||
if err != nil || !found || resolved.ID != owner.ID {
|
||||
t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err)
|
||||
}
|
||||
if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err)
|
||||
}
|
||||
phoneUser, found, err := svc.ResolvePhone(ctx, owner.ID, "+1 (555) 000-0002")
|
||||
if err != nil || !found || phoneUser.ID != other.ID {
|
||||
t.Fatalf("ResolvePhone = user %+v found %v err %v, want other", phoneUser, found, err)
|
||||
}
|
||||
cleared, err := svc.UpdateUsername(ctx, owner.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("clear username: %v", err)
|
||||
}
|
||||
if cleared.Username != "" {
|
||||
t.Fatalf("cleared username = %q, want empty", cleared.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner", LastName: "Old"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
svc := NewService(store)
|
||||
|
||||
updated, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{
|
||||
FirstName: " New ",
|
||||
HasFirstName: true,
|
||||
LastName: "Name",
|
||||
HasLastName: true,
|
||||
About: "bio",
|
||||
HasAbout: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
if updated.FirstName != "New" || updated.LastName != "Name" || updated.About != "bio" {
|
||||
t.Fatalf("updated profile = %+v, want trimmed names and about", updated)
|
||||
}
|
||||
if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: " ", HasFirstName: true}); !errors.Is(err, domain.ErrFirstNameInvalid) {
|
||||
t.Fatalf("empty first name err = %v, want first name invalid", err)
|
||||
}
|
||||
if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{About: strings.Repeat("x", 71), HasAbout: true}); !errors.Is(err, domain.ErrAboutTooLong) {
|
||||
t.Fatalf("long about err = %v, want about too long", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceByIDDoesNotReloadSelf(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
store := &countingUserStore{UserStore: base}
|
||||
svc := NewService(store)
|
||||
|
||||
got, found, err := svc.ByID(ctx, owner.ID, target.ID)
|
||||
if err != nil || !found || got.ID != target.ID {
|
||||
t.Fatalf("ByID = %+v found %v err %v, want target", got, found, err)
|
||||
}
|
||||
if store.byIDCalls != 1 {
|
||||
t.Fatalf("store ByID calls = %d, want 1 target lookup only", store.byIDCalls)
|
||||
}
|
||||
if store.lastByID != target.ID {
|
||||
t.Fatalf("last ByID id = %d, want target %d", store.lastByID, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
type countingUserStore struct {
|
||||
*memory.UserStore
|
||||
byIDCalls int
|
||||
lastByID int64
|
||||
}
|
||||
|
||||
func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) {
|
||||
s.byIDCalls++
|
||||
s.lastByID = id
|
||||
return s.UserStore.ByID(ctx, id)
|
||||
}
|
||||
56
internal/compat/tdesktop/config.go
Normal file
56
internal/compat/tdesktop/config.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
// BuildConfig 构造 help.getConfig 返回的 tg.Config,含自建 DC 的 DCOptions。
|
||||
//
|
||||
// 字段值取 Telegram 常见默认;TDesktop 联调阶段按客户端实际需要微调
|
||||
// (记录于 docs/compatibility-matrix.md)。
|
||||
func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
|
||||
return &tg.Config{
|
||||
Date: int(now.Unix()),
|
||||
Expires: int(now.Add(time.Hour).Unix()),
|
||||
TestMode: false,
|
||||
ThisDC: dc,
|
||||
DCOptions: []tg.DCOption{
|
||||
{ID: dc, IPAddress: ip, Port: port, Static: true},
|
||||
},
|
||||
ChatSizeMax: 200,
|
||||
MegagroupSizeMax: 200000,
|
||||
ForwardedCountMax: 100,
|
||||
OnlineUpdatePeriodMs: 120000,
|
||||
OfflineBlurTimeoutMs: 5000,
|
||||
OfflineIdleTimeoutMs: 30000,
|
||||
OnlineCloudTimeoutMs: 300000,
|
||||
NotifyCloudDelayMs: 30000,
|
||||
NotifyDefaultDelayMs: 1500,
|
||||
PushChatPeriodMs: 60000,
|
||||
PushChatLimit: 2,
|
||||
EditTimeLimit: 172800,
|
||||
RevokeTimeLimit: 172800,
|
||||
RevokePmTimeLimit: 172800,
|
||||
RatingEDecay: 2419200,
|
||||
StickersRecentLimit: 200,
|
||||
CallReceiveTimeoutMs: 20000,
|
||||
CallRingTimeoutMs: 90000,
|
||||
CallConnectTimeoutMs: 30000,
|
||||
CallPacketTimeoutMs: 10000,
|
||||
MeURLPrefix: "https://t.me/",
|
||||
CaptionLengthMax: 1024,
|
||||
MessageLengthMax: 4096,
|
||||
WebfileDCID: dc,
|
||||
}
|
||||
}
|
||||
|
||||
// NearestDC 构造 help.getNearestDc 返回值。
|
||||
func NearestDC(dc int) *tg.NearestDC {
|
||||
return &tg.NearestDC{
|
||||
Country: "US",
|
||||
ThisDC: dc,
|
||||
NearestDC: dc,
|
||||
}
|
||||
}
|
||||
96
internal/compat/tdesktop/defaults.go
Normal file
96
internal/compat/tdesktop/defaults.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
appConfigHash = 4
|
||||
countriesListHash = 1
|
||||
timezonesListHash = 1
|
||||
)
|
||||
|
||||
// AppConfig returns the fallback TDesktop startup app config used when HelpService is absent.
|
||||
func AppConfig(hash int) tg.HelpAppConfigClass {
|
||||
if hash == appConfigHash {
|
||||
return &tg.HelpAppConfigNotModified{}
|
||||
}
|
||||
return &tg.HelpAppConfig{
|
||||
Hash: appConfigHash,
|
||||
Config: readMarkAppConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func readMarkAppConfig() *tg.JSONObject {
|
||||
return &tg.JSONObject{Value: []tg.JSONObjectValue{
|
||||
{Key: "chat_read_mark_size_threshold", Value: &tg.JSONNumber{Value: 50}},
|
||||
{Key: "chat_read_mark_expire_period", Value: &tg.JSONNumber{Value: 604800}},
|
||||
{Key: "pm_read_date_expire_period", Value: &tg.JSONNumber{Value: 604800}},
|
||||
{Key: "quote_length_max", Value: &tg.JSONNumber{Value: 1024}},
|
||||
{Key: "telegram_antispam_group_size_min", Value: &tg.JSONNumber{Value: 200}},
|
||||
{Key: "telegram_antispam_user_id", Value: &tg.JSONString{Value: "5434988373"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading.
|
||||
func TimezonesList(hash int) tg.HelpTimezonesListClass {
|
||||
if hash == timezonesListHash {
|
||||
return &tg.HelpTimezonesListNotModified{}
|
||||
}
|
||||
return &tg.HelpTimezonesList{
|
||||
Hash: timezonesListHash,
|
||||
Timezones: []tg.Timezone{
|
||||
{ID: "Etc/UTC", Name: "UTC", UtcOffset: 0},
|
||||
{ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60},
|
||||
{ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60},
|
||||
{ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60},
|
||||
{ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60},
|
||||
{ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CountriesList returns the fallback login country list used when HelpService is absent.
|
||||
func CountriesList(hash int) tg.HelpCountriesListClass {
|
||||
if hash == countriesListHash {
|
||||
return &tg.HelpCountriesListNotModified{}
|
||||
}
|
||||
return &tg.HelpCountriesList{
|
||||
Hash: countriesListHash,
|
||||
Countries: []tg.HelpCountry{
|
||||
{
|
||||
ISO2: "US",
|
||||
DefaultName: "United States",
|
||||
CountryCodes: []tg.HelpCountryCode{
|
||||
{CountryCode: "1", Prefixes: []string{"1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ISO2: "CN",
|
||||
DefaultName: "China",
|
||||
CountryCodes: []tg.HelpCountryCode{
|
||||
{CountryCode: "86", Prefixes: []string{"86"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoginToken returns a short-lived placeholder token for TDesktop's QR-login
|
||||
// screen. First phase only supports phone login, but TDesktop expects this RPC
|
||||
// to produce an auth.loginToken while the QR screen is visible.
|
||||
func LoginToken(now time.Time, authKeyID [8]byte, sessionID int64) *tg.AuthLoginToken {
|
||||
var seed [len(authKeyID) + 8 + 8]byte
|
||||
copy(seed[:len(authKeyID)], authKeyID[:])
|
||||
binary.LittleEndian.PutUint64(seed[len(authKeyID):], uint64(sessionID))
|
||||
binary.LittleEndian.PutUint64(seed[len(authKeyID)+8:], uint64(now.UnixNano()))
|
||||
token := sha256.Sum256(seed[:])
|
||||
return &tg.AuthLoginToken{
|
||||
Expires: int(now.Add(30 * time.Second).Unix()),
|
||||
Token: token[:],
|
||||
}
|
||||
}
|
||||
8
internal/compat/tdesktop/doc.go
Normal file
8
internal/compat/tdesktop/doc.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Package tdesktop 集中存放 Telegram Desktop 兼容逻辑:目标版本/layer 记录、客户端 patch 说明、
|
||||
// 启动 RPC 顺序、兼容矩阵辅助、TDesktop 专属 stub 与 feature flags。
|
||||
//
|
||||
// 兼容代码只允许出现在本包,禁止散落进业务 handler。后续 Android/iOS 另开 internal/compat/android|ios。
|
||||
// 当前基线:TDesktop dev 9caf32dffc(v6.8.4+15),Layer 225。
|
||||
//
|
||||
// TDesktop 兼容逻辑集中在这里,避免散落到业务服务中。
|
||||
package tdesktop
|
||||
278
internal/compat/tdesktop/startup_stubs.go
Normal file
278
internal/compat/tdesktop/startup_stubs.go
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
// NotifySettings returns default per-peer notification settings for empty first-phase accounts.
|
||||
func NotifySettings() *tg.PeerNotifySettings {
|
||||
settings := &tg.PeerNotifySettings{}
|
||||
settings.SetShowPreviews(true)
|
||||
settings.SetSilent(false)
|
||||
settings.SetMuteUntil(0)
|
||||
settings.SetIosSound(&tg.NotificationSoundDefault{})
|
||||
settings.SetAndroidSound(&tg.NotificationSoundDefault{})
|
||||
settings.SetOtherSound(&tg.NotificationSoundDefault{})
|
||||
settings.SetStoriesMuted(false)
|
||||
settings.SetStoriesHideSender(false)
|
||||
settings.SetStoriesIosSound(&tg.NotificationSoundDefault{})
|
||||
settings.SetStoriesAndroidSound(&tg.NotificationSoundDefault{})
|
||||
settings.SetStoriesOtherSound(&tg.NotificationSoundDefault{})
|
||||
return settings
|
||||
}
|
||||
|
||||
// ReactionsNotifySettings returns conservative defaults for reaction notifications.
|
||||
func ReactionsNotifySettings() *tg.ReactionsNotifySettings {
|
||||
return &tg.ReactionsNotifySettings{
|
||||
MessagesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
|
||||
StoriesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
|
||||
PollVotesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
|
||||
Sound: &tg.NotificationSoundDefault{},
|
||||
ShowPreviews: true,
|
||||
}
|
||||
}
|
||||
|
||||
func PrivacyRules(key tg.InputPrivacyKeyClass) *tg.AccountPrivacyRules {
|
||||
var rule tg.PrivacyRuleClass = &tg.PrivacyValueAllowAll{}
|
||||
switch key.(type) {
|
||||
case *tg.InputPrivacyKeyPhoneNumber:
|
||||
rule = &tg.PrivacyValueDisallowAll{}
|
||||
case *tg.InputPrivacyKeyBirthday:
|
||||
rule = &tg.PrivacyValueAllowContacts{}
|
||||
}
|
||||
return &tg.AccountPrivacyRules{
|
||||
Rules: []tg.PrivacyRuleClass{rule},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func Authorizations() *tg.AccountAuthorizations {
|
||||
return &tg.AccountAuthorizations{Authorizations: []tg.Authorization{}}
|
||||
}
|
||||
|
||||
func Passkeys() *tg.AccountPasskeys {
|
||||
return &tg.AccountPasskeys{Passkeys: []tg.Passkey{}}
|
||||
}
|
||||
|
||||
func ContentSettings() *tg.AccountContentSettings {
|
||||
return &tg.AccountContentSettings{}
|
||||
}
|
||||
|
||||
func GlobalPrivacySettings() *tg.GlobalPrivacySettings {
|
||||
return &tg.GlobalPrivacySettings{}
|
||||
}
|
||||
|
||||
func AccountThemes() tg.AccountThemesClass {
|
||||
return &tg.AccountThemesNotModified{}
|
||||
}
|
||||
|
||||
func DefaultEmojiStatuses() tg.AccountEmojiStatusesClass {
|
||||
return &tg.AccountEmojiStatusesNotModified{}
|
||||
}
|
||||
|
||||
func CollectibleEmojiStatuses() tg.AccountEmojiStatusesClass {
|
||||
return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}}
|
||||
}
|
||||
|
||||
func DefaultGroupPhotoEmojis() tg.EmojiListClass {
|
||||
return &tg.EmojiList{Hash: 0, DocumentID: []int64{}}
|
||||
}
|
||||
|
||||
func ConnectedBots() *tg.AccountConnectedBots {
|
||||
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}
|
||||
}
|
||||
|
||||
const availableReactionsHash = 20260602
|
||||
const emptyStickerSetHash = 20260602
|
||||
|
||||
type defaultReaction struct {
|
||||
emoticon string
|
||||
title string
|
||||
}
|
||||
|
||||
var defaultAvailableReactions = []defaultReaction{
|
||||
{emoticon: "\U0001f44d", title: "Thumbs Up"},
|
||||
{emoticon: "\u2764\ufe0f", title: "Red Heart"},
|
||||
{emoticon: "\U0001f602", title: "Face With Tears of Joy"},
|
||||
{emoticon: "\U0001f62e", title: "Face With Open Mouth"},
|
||||
{emoticon: "\U0001f622", title: "Crying Face"},
|
||||
{emoticon: "\U0001f64f", title: "Folded Hands"},
|
||||
}
|
||||
|
||||
// DefaultReactionEmoticons returns the TDesktop-compatible emoji reaction catalog order.
|
||||
func DefaultReactionEmoticons() []string {
|
||||
out := make([]string, 0, len(defaultAvailableReactions))
|
||||
for _, reaction := range defaultAvailableReactions {
|
||||
out = append(out, reaction.emoticon)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func AvailableReactions(hash int) tg.MessagesAvailableReactionsClass {
|
||||
if hash == availableReactionsHash {
|
||||
return &tg.MessagesAvailableReactionsNotModified{}
|
||||
}
|
||||
reactions := make([]tg.AvailableReaction, 0, len(defaultAvailableReactions))
|
||||
for i, reaction := range defaultAvailableReactions {
|
||||
reactions = append(reactions, availableReaction(reaction, i))
|
||||
}
|
||||
return &tg.MessagesAvailableReactions{
|
||||
Hash: availableReactionsHash,
|
||||
Reactions: reactions,
|
||||
}
|
||||
}
|
||||
|
||||
func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction {
|
||||
const documentBaseID int64 = 900000000000000000
|
||||
doc := func(slot int64) tg.DocumentClass {
|
||||
return &tg.DocumentEmpty{ID: documentBaseID + int64(index)*10 + slot}
|
||||
}
|
||||
return tg.AvailableReaction{
|
||||
Reaction: reaction.emoticon,
|
||||
Title: reaction.title,
|
||||
StaticIcon: doc(1),
|
||||
AppearAnimation: doc(2),
|
||||
SelectAnimation: doc(3),
|
||||
ActivateAnimation: doc(4),
|
||||
EffectAnimation: doc(5),
|
||||
}
|
||||
}
|
||||
|
||||
func Stickers() tg.MessagesStickersClass {
|
||||
return &tg.MessagesStickersNotModified{}
|
||||
}
|
||||
|
||||
func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass {
|
||||
if req != nil && req.Hash == emptyStickerSetHash {
|
||||
return &tg.MessagesStickerSetNotModified{}
|
||||
}
|
||||
title, shortName := "Telesrv Empty Sticker Set", "telesrv_empty"
|
||||
if req != nil {
|
||||
switch set := req.Stickerset.(type) {
|
||||
case *tg.InputStickerSetAnimatedEmoji:
|
||||
title, shortName = "Animated Emoji", "AnimatedEmojies"
|
||||
case *tg.InputStickerSetAnimatedEmojiAnimations:
|
||||
title, shortName = "Emoji Animations", "EmojiAnimations"
|
||||
case *tg.InputStickerSetEmojiGenericAnimations:
|
||||
title, shortName = "Emoji Generic Animations", "EmojiGenericAnimations"
|
||||
case *tg.InputStickerSetDice:
|
||||
title, shortName = "Dice Animations", "AnimatedDices"
|
||||
if set.Emoticon != "" {
|
||||
shortName = "AnimatedDice"
|
||||
}
|
||||
case *tg.InputStickerSetPremiumGifts:
|
||||
title, shortName = "Premium Gifts", "GiftsPremium"
|
||||
case *tg.InputStickerSetShortName:
|
||||
if set.ShortName != "" {
|
||||
title, shortName = set.ShortName, set.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
return &tg.MessagesStickerSet{
|
||||
Set: tg.StickerSet{
|
||||
ID: 910000000000000000,
|
||||
AccessHash: 910000000000000001,
|
||||
Title: title,
|
||||
ShortName: shortName,
|
||||
Count: 0,
|
||||
Hash: emptyStickerSetHash,
|
||||
},
|
||||
Packs: []tg.StickerPack{},
|
||||
Keywords: []tg.StickerKeyword{},
|
||||
Documents: []tg.DocumentClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func EmojiGroups() tg.MessagesEmojiGroupsClass {
|
||||
return &tg.MessagesEmojiGroupsNotModified{}
|
||||
}
|
||||
|
||||
func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass {
|
||||
return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}}
|
||||
}
|
||||
|
||||
func AttachMenuBots() tg.AttachMenuBotsClass {
|
||||
return &tg.AttachMenuBotsNotModified{}
|
||||
}
|
||||
|
||||
func QuickReplies() tg.MessagesQuickRepliesClass {
|
||||
return &tg.MessagesQuickRepliesNotModified{}
|
||||
}
|
||||
|
||||
func TopPeers() tg.ContactsTopPeersClass {
|
||||
return &tg.ContactsTopPeersDisabled{}
|
||||
}
|
||||
|
||||
func BlockedContacts() tg.ContactsBlockedClass {
|
||||
return &tg.ContactsBlocked{
|
||||
Blocked: []tg.PeerBlocked{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func PeerColors() tg.HelpPeerColorsClass {
|
||||
return &tg.HelpPeerColorsNotModified{}
|
||||
}
|
||||
|
||||
func PromoData(now time.Time) tg.HelpPromoDataClass {
|
||||
return &tg.HelpPromoDataEmpty{Expires: int(now.Add(time.Hour).Unix())}
|
||||
}
|
||||
|
||||
func TermsOfServiceUpdate(now time.Time) tg.HelpTermsOfServiceUpdateClass {
|
||||
return &tg.HelpTermsOfServiceUpdateEmpty{Expires: int(now.Add(24 * time.Hour).Unix())}
|
||||
}
|
||||
|
||||
func PremiumPromo() *tg.HelpPremiumPromo {
|
||||
return &tg.HelpPremiumPromo{}
|
||||
}
|
||||
|
||||
func AllStories() tg.StoriesAllStoriesClass {
|
||||
return &tg.StoriesAllStories{
|
||||
State: "",
|
||||
StealthMode: tg.StoriesStealthMode{},
|
||||
}
|
||||
}
|
||||
|
||||
func StoriesArchive() *tg.StoriesStories {
|
||||
return &tg.StoriesStories{}
|
||||
}
|
||||
|
||||
func PinnedStories() *tg.StoriesStories {
|
||||
return &tg.StoriesStories{}
|
||||
}
|
||||
|
||||
func StoryAlbums() tg.StoriesAlbumsClass {
|
||||
return &tg.StoriesAlbums{Hash: 0, Albums: []tg.StoryAlbum{}}
|
||||
}
|
||||
|
||||
func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass {
|
||||
return &tg.PaymentsStarGiftActiveAuctionsNotModified{}
|
||||
}
|
||||
|
||||
func SavedStarGifts() *tg.PaymentsSavedStarGifts {
|
||||
return &tg.PaymentsSavedStarGifts{
|
||||
Gifts: []tg.SavedStarGift{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func AiComposeTones() tg.AicomposeTonesClass {
|
||||
return &tg.AicomposeTonesNotModified{}
|
||||
}
|
||||
|
||||
func WebPage(url string) *tg.MessagesWebPage {
|
||||
page := &tg.WebPageEmpty{ID: 0}
|
||||
if url != "" {
|
||||
page.SetURL(url)
|
||||
}
|
||||
return &tg.MessagesWebPage{
|
||||
Webpage: page,
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
157
internal/compat/tdesktop/startup_stubs_test.go
Normal file
157
internal/compat/tdesktop/startup_stubs_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func TestNotifySettingsDefaultIsAudible(t *testing.T) {
|
||||
settings := NotifySettings()
|
||||
if value, ok := settings.GetShowPreviews(); !ok || !value {
|
||||
t.Fatalf("show_previews = %v ok=%v, want true", value, ok)
|
||||
}
|
||||
if value, ok := settings.GetSilent(); !ok || value {
|
||||
t.Fatalf("silent = %v ok=%v, want explicit false", value, ok)
|
||||
}
|
||||
if value, ok := settings.GetMuteUntil(); !ok || value != 0 {
|
||||
t.Fatalf("mute_until = %d ok=%v, want explicit 0", value, ok)
|
||||
}
|
||||
if value, ok := settings.GetOtherSound(); !ok || value == nil {
|
||||
t.Fatalf("other_sound = %#v ok=%v, want default sound", value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) {
|
||||
got, ok := TimezonesList(0).(*tg.HelpTimezonesList)
|
||||
if !ok || got.Hash == 0 || len(got.Timezones) == 0 {
|
||||
t.Fatalf("TimezonesList(0) = %#v, want non-empty modified list", got)
|
||||
}
|
||||
if _, ok := TimezonesList(got.Hash).(*tg.HelpTimezonesListNotModified); !ok {
|
||||
t.Fatalf("TimezonesList(hash) = %#v, want notModified", TimezonesList(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) {
|
||||
got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions)
|
||||
if !ok {
|
||||
t.Fatalf("AvailableReactions(0) = %T, want modified list", got)
|
||||
}
|
||||
if got.Hash == 0 {
|
||||
t.Fatal("AvailableReactions(0).Hash = 0, want stable cache hash")
|
||||
}
|
||||
if len(got.Reactions) == 0 {
|
||||
t.Fatal("AvailableReactions(0).Reactions is empty")
|
||||
}
|
||||
for i, reaction := range got.Reactions {
|
||||
if reaction.Reaction == "" {
|
||||
t.Fatalf("reaction[%d].Reaction is empty", i)
|
||||
}
|
||||
if reaction.Title == "" {
|
||||
t.Fatalf("reaction[%d].Title is empty", i)
|
||||
}
|
||||
if reaction.StaticIcon == nil ||
|
||||
reaction.AppearAnimation == nil ||
|
||||
reaction.SelectAnimation == nil ||
|
||||
reaction.ActivateAnimation == nil ||
|
||||
reaction.EffectAnimation == nil {
|
||||
t.Fatalf("reaction[%d] has nil required document: %#v", i, reaction)
|
||||
}
|
||||
if reaction.Inactive || reaction.Premium {
|
||||
t.Fatalf("reaction[%d] flags = inactive %v premium %v, want active non-premium", i, reaction.Inactive, reaction.Premium)
|
||||
}
|
||||
}
|
||||
if _, ok := AvailableReactions(got.Hash).(*tg.MessagesAvailableReactionsNotModified); !ok {
|
||||
t.Fatalf("AvailableReactions(hash) = %#v, want notModified", AvailableReactions(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses)
|
||||
if !ok {
|
||||
t.Fatalf("CollectibleEmojiStatuses() = %T, want empty modified list", got)
|
||||
}
|
||||
if got.Hash != 0 {
|
||||
t.Fatalf("CollectibleEmojiStatuses().Hash = %d, want 0", got.Hash)
|
||||
}
|
||||
if len(got.Statuses) != 0 {
|
||||
t.Fatalf("CollectibleEmojiStatuses().Statuses length = %d, want 0", len(got.Statuses))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGroupPhotoEmojisIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := DefaultGroupPhotoEmojis().(*tg.EmojiList)
|
||||
if !ok {
|
||||
t.Fatalf("DefaultGroupPhotoEmojis() = %T, want empty modified list", got)
|
||||
}
|
||||
if got.Hash != 0 {
|
||||
t.Fatalf("DefaultGroupPhotoEmojis().Hash = %d, want 0", got.Hash)
|
||||
}
|
||||
if len(got.DocumentID) != 0 {
|
||||
t.Fatalf("DefaultGroupPhotoEmojis().DocumentID length = %d, want 0", len(got.DocumentID))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := EmojiProfilePhotoGroups().(*tg.MessagesEmojiGroups)
|
||||
if !ok {
|
||||
t.Fatalf("EmojiProfilePhotoGroups() = %T, want empty modified list", got)
|
||||
}
|
||||
if got.Hash != 0 {
|
||||
t.Fatalf("EmojiProfilePhotoGroups().Hash = %d, want 0", got.Hash)
|
||||
}
|
||||
if len(got.Groups) != 0 {
|
||||
t.Fatalf("EmojiProfilePhotoGroups().Groups length = %d, want 0", len(got.Groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectedBotsIsEmptyList(t *testing.T) {
|
||||
got := ConnectedBots()
|
||||
if got == nil {
|
||||
t.Fatal("ConnectedBots() = nil")
|
||||
}
|
||||
if len(got.ConnectedBots) != 0 || len(got.Users) != 0 {
|
||||
t.Fatalf("ConnectedBots() = bots %d users %d, want empty vectors", len(got.ConnectedBots), len(got.Users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoryAlbumsIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := StoryAlbums().(*tg.StoriesAlbums)
|
||||
if !ok {
|
||||
t.Fatalf("StoryAlbums() = %T, want empty modified list", got)
|
||||
}
|
||||
if got.Hash != 0 {
|
||||
t.Fatalf("StoryAlbums().Hash = %d, want 0", got.Hash)
|
||||
}
|
||||
if len(got.Albums) != 0 {
|
||||
t.Fatalf("StoryAlbums().Albums length = %d, want 0", len(got.Albums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStickerSetReturnsEmptyModifiedSetForColdRequest(t *testing.T) {
|
||||
got, ok := StickerSet(&tg.MessagesGetStickerSetRequest{
|
||||
Stickerset: &tg.InputStickerSetEmojiGenericAnimations{},
|
||||
Hash: 0,
|
||||
}).(*tg.MessagesStickerSet)
|
||||
if !ok {
|
||||
t.Fatalf("StickerSet(hash=0) = %T, want modified empty set", got)
|
||||
}
|
||||
if got.Set.Hash == 0 {
|
||||
t.Fatal("StickerSet(hash=0).Set.Hash = 0, want stable cache hash")
|
||||
}
|
||||
if got.Set.ShortName != "EmojiGenericAnimations" {
|
||||
t.Fatalf("StickerSet(hash=0).Set.ShortName = %q, want EmojiGenericAnimations", got.Set.ShortName)
|
||||
}
|
||||
if len(got.Packs) != 0 || len(got.Keywords) != 0 || len(got.Documents) != 0 {
|
||||
t.Fatalf("StickerSet(hash=0) = packs %d keywords %d documents %d, want empty vectors", len(got.Packs), len(got.Keywords), len(got.Documents))
|
||||
}
|
||||
if _, ok := StickerSet(&tg.MessagesGetStickerSetRequest{
|
||||
Stickerset: &tg.InputStickerSetEmojiGenericAnimations{},
|
||||
Hash: got.Set.Hash,
|
||||
}).(*tg.MessagesStickerSetNotModified); !ok {
|
||||
t.Fatalf("StickerSet(hash) = %#v, want notModified", StickerSet(&tg.MessagesGetStickerSetRequest{
|
||||
Stickerset: &tg.InputStickerSetEmojiGenericAnimations{},
|
||||
Hash: got.Set.Hash,
|
||||
}))
|
||||
}
|
||||
}
|
||||
127
internal/config/config.go
Normal file
127
internal/config/config.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Package config 负责 telesrv 运行配置的加载与校验。
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 是 telesrv 的运行配置。
|
||||
type Config struct {
|
||||
// ListenAddr 是 MTProto TCP 监听地址。
|
||||
// 需与 TDesktop patch 指向的自建 DC 地址/端口一致(记录于 docs/tdesktop-patch-notes.md)。
|
||||
ListenAddr string
|
||||
// AdvertiseIP 是写入 help.getConfig DCOptions 的对外可达 IP(客户端据此连接本 DC)。
|
||||
AdvertiseIP string
|
||||
// RSAKeyPath 是 server RSA 私钥的 PEM 路径;不存在时自动生成。
|
||||
RSAKeyPath string
|
||||
// DC 是本 server 的 DC ID。
|
||||
DC int
|
||||
|
||||
// PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。
|
||||
// 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。
|
||||
PostgresDSN string
|
||||
// PostgresMaxConns 是 pgxpool 最大连接数。<=0 用 pgx 默认(max(4, NumCPU),生产偏小)。
|
||||
// 需覆盖发送事务 + outbox worker 并发 + RPC 读,过小会在高并发下排队(表现为尾延迟突刺)。
|
||||
PostgresMaxConns int
|
||||
// PostgresMinConns 是启动时预热的 pgxpool 连接数,降低 TDesktop 冷启动并发 RPC 的建连等待。
|
||||
PostgresMinConns int
|
||||
// RedisAddr 是高频易失态(验证码、限流计数、update 队列)的 Redis 地址。
|
||||
RedisAddr string
|
||||
// RedisPassword 是 Redis 密码;开发默认空。
|
||||
RedisPassword string
|
||||
// RedisDB 是 Redis 逻辑库编号。
|
||||
RedisDB int
|
||||
|
||||
// DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。
|
||||
DevAuthCode string
|
||||
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
|
||||
LangPackSeedDir string
|
||||
// BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。
|
||||
BlobDir string
|
||||
// StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。
|
||||
StickerSeedDir string
|
||||
// StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。
|
||||
StickerSeedMaxSets int
|
||||
|
||||
// OutboxWorkers 是并发 claim 的 outbox worker 数。worker 间用 FOR UPDATE SKIP LOCKED 互不重叠。
|
||||
// 开发默认偏保守,避免 TDesktop 启动风暴下多个 worker 同扫分区父表触发 PG lock/shared-memory 压力;
|
||||
// 压测或生产可按硬件用 TELESRV_OUTBOX_WORKERS 调高。
|
||||
OutboxWorkers int
|
||||
// OutboxBatch 是 transactional outbox worker 每次 claim 的最大条数。
|
||||
// 调大提升吞吐、增大单批 PG/推送压力;调小降低延迟抖动。配套压测见 docs/message-module.md。
|
||||
OutboxBatch int
|
||||
// OutboxInterval 是 outbox worker 两次 claim 之间的轮询间隔。
|
||||
OutboxInterval time.Duration
|
||||
// OutboxLeaseTimeout 是 'dispatching' 行被判定为租约过期、允许其它 worker 重新 claim 的时长。
|
||||
// 取值需大于单批投递耗时,否则会重复推送;过大则 worker 崩溃后积压恢复变慢。
|
||||
OutboxLeaseTimeout time.Duration
|
||||
// OutboundPushTimeout 是 best-effort updates 推送等待 outbound 队列接受的最长时间。
|
||||
OutboundPushTimeout time.Duration
|
||||
// UpdateEventRetention 是 durable update log 保留期;只清理已被水位/state 覆盖的事件。
|
||||
UpdateEventRetention time.Duration
|
||||
// RetentionInterval 是 retention worker 的运行间隔。
|
||||
RetentionInterval time.Duration
|
||||
// RetentionBatch 是单次 retention 最多删除的行数。
|
||||
RetentionBatch int
|
||||
}
|
||||
|
||||
// Load 从环境变量读取配置并填充默认值。第一阶段不做严格校验。
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
|
||||
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
|
||||
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
|
||||
DC: envIntOr("TELESRV_DC", 2),
|
||||
|
||||
PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"),
|
||||
PostgresMaxConns: envIntOr("TELESRV_POSTGRES_MAX_CONNS", 50),
|
||||
PostgresMinConns: envIntOr("TELESRV_POSTGRES_MIN_CONNS", 16),
|
||||
RedisAddr: envOr("TELESRV_REDIS_ADDR", "localhost:6399"),
|
||||
RedisPassword: envOr("TELESRV_REDIS_PASSWORD", ""),
|
||||
RedisDB: envIntOr("TELESRV_REDIS_DB", 0),
|
||||
|
||||
DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
||||
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 40),
|
||||
|
||||
OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 2),
|
||||
OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100),
|
||||
OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond),
|
||||
OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second),
|
||||
OutboundPushTimeout: envDurationOr("TELESRV_OUTBOUND_PUSH_TIMEOUT", 200*time.Millisecond),
|
||||
UpdateEventRetention: envDurationOr("TELESRV_UPDATE_EVENT_RETENTION", 168*time.Hour),
|
||||
RetentionInterval: envDurationOr("TELESRV_RETENTION_INTERVAL", time.Hour),
|
||||
RetentionBatch: envIntOr("TELESRV_RETENTION_BATCH", 10000),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envIntOr(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// envDurationOr 读取 time.ParseDuration 格式(如 "200ms"、"30s")的时长配置;解析失败回退默认值。
|
||||
func envDurationOr(key string, def time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
65
internal/domain/account.go
Normal file
65
internal/domain/account.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package domain
|
||||
|
||||
// PasswordSettings 是账号 2FA/SRP 配置。第一阶段默认 HasPassword=false。
|
||||
type PasswordSettings struct {
|
||||
HasRecovery bool
|
||||
HasSecureValues bool
|
||||
HasPassword bool
|
||||
Hint string
|
||||
EmailUnconfirmedPattern string
|
||||
LoginEmailPattern string
|
||||
SecureRandom []byte
|
||||
}
|
||||
|
||||
// ReactionNotifyFrom stores one account-level reaction notification scope.
|
||||
type ReactionNotifyFrom string
|
||||
|
||||
const (
|
||||
ReactionNotifyFromNone ReactionNotifyFrom = "none"
|
||||
ReactionNotifyFromContacts ReactionNotifyFrom = "contacts"
|
||||
ReactionNotifyFromAll ReactionNotifyFrom = "all"
|
||||
)
|
||||
|
||||
// ReactionsNotifySettings stores the account reaction notification settings
|
||||
// consumed by account.get/setReactionsNotifySettings.
|
||||
type ReactionsNotifySettings struct {
|
||||
MessagesFrom ReactionNotifyFrom
|
||||
StoriesFrom ReactionNotifyFrom
|
||||
PollVotesFrom ReactionNotifyFrom
|
||||
ShowPreviews bool
|
||||
}
|
||||
|
||||
// PaidReactionPrivacyKind stores the account default paid reaction privacy.
|
||||
type PaidReactionPrivacyKind string
|
||||
|
||||
const (
|
||||
PaidReactionPrivacyDefault PaidReactionPrivacyKind = "default"
|
||||
PaidReactionPrivacyAnonymous PaidReactionPrivacyKind = "anonymous"
|
||||
PaidReactionPrivacyPeer PaidReactionPrivacyKind = "peer"
|
||||
)
|
||||
|
||||
// PaidReactionPrivacy is the domain representation of tg.PaidReactionPrivacy.
|
||||
type PaidReactionPrivacy struct {
|
||||
Kind PaidReactionPrivacyKind
|
||||
Peer *Peer
|
||||
}
|
||||
|
||||
// AccountReactionSettings groups account-level reaction preferences.
|
||||
type AccountReactionSettings struct {
|
||||
Notify ReactionsNotifySettings
|
||||
DefaultReaction MessageReaction
|
||||
PaidPrivacy PaidReactionPrivacy
|
||||
}
|
||||
|
||||
func DefaultAccountReactionSettings() AccountReactionSettings {
|
||||
return AccountReactionSettings{
|
||||
Notify: ReactionsNotifySettings{
|
||||
MessagesFrom: ReactionNotifyFromContacts,
|
||||
StoriesFrom: ReactionNotifyFromContacts,
|
||||
PollVotesFrom: ReactionNotifyFromContacts,
|
||||
ShowPreviews: true,
|
||||
},
|
||||
DefaultReaction: MessageReaction{Type: MessageReactionEmoji, Emoticon: "👍"},
|
||||
PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault},
|
||||
}
|
||||
}
|
||||
15
internal/domain/authorization.go
Normal file
15
internal/domain/authorization.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package domain
|
||||
|
||||
// Authorization 是一条设备授权:auth_key 与 user 的绑定 + initConnection 设备信息。
|
||||
// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。
|
||||
type Authorization struct {
|
||||
AuthKeyID [8]byte // 协议原生 auth_key_id;store 边界按小端转 int64
|
||||
UserID int64
|
||||
Layer int
|
||||
DeviceModel string
|
||||
Platform string
|
||||
SystemVersion string
|
||||
APIID int
|
||||
AppVersion string
|
||||
IP string
|
||||
}
|
||||
1546
internal/domain/channel.go
Normal file
1546
internal/domain/channel.go
Normal file
File diff suppressed because it is too large
Load diff
68
internal/domain/channel_errors.go
Normal file
68
internal/domain/channel_errors.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelInvalid = errors.New("channel invalid")
|
||||
ErrChannelPrivate = errors.New("channel private")
|
||||
ErrChannelTitleInvalid = errors.New("channel title invalid")
|
||||
ErrChannelUserBanned = errors.New("user banned in channel")
|
||||
ErrChannelWriteForbidden = errors.New("chat write forbidden")
|
||||
ErrChannelAdminRequired = errors.New("chat admin required")
|
||||
ErrChannelNotModified = errors.New("chat not modified")
|
||||
ErrChannelForumMissing = errors.New("channel forum missing")
|
||||
ErrLinkNotModified = errors.New("discussion link not modified")
|
||||
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
|
||||
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
|
||||
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
|
||||
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
|
||||
ErrChatPublicRequired = errors.New("chat public required")
|
||||
ErrChannelUserCreator = errors.New("channel user creator")
|
||||
ErrChannelRightForbidden = errors.New("channel right forbidden")
|
||||
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
|
||||
ErrInviteHashEmpty = errors.New("invite hash empty")
|
||||
ErrInviteHashInvalid = errors.New("invite hash invalid")
|
||||
ErrInviteHashExpired = errors.New("invite hash expired")
|
||||
ErrInvitePermanent = errors.New("chat invite permanent")
|
||||
ErrInviteRevokedMissing = errors.New("invite revoked missing")
|
||||
ErrInviteRequestSent = errors.New("invite request sent")
|
||||
ErrHideRequesterMissing = errors.New("hide requester missing")
|
||||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
)
|
||||
|
||||
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.
|
||||
type SlowModeWaitError struct {
|
||||
Seconds int
|
||||
}
|
||||
|
||||
func (e SlowModeWaitError) Error() string {
|
||||
if e.Seconds <= 0 {
|
||||
return "slowmode wait"
|
||||
}
|
||||
return fmt.Sprintf("slowmode wait %d seconds", e.Seconds)
|
||||
}
|
||||
|
||||
// NewSlowModeWaitError creates a bounded slow mode wait error.
|
||||
func NewSlowModeWaitError(seconds int) error {
|
||||
if seconds <= 0 {
|
||||
seconds = 1
|
||||
}
|
||||
return SlowModeWaitError{Seconds: seconds}
|
||||
}
|
||||
|
||||
// SlowModeWaitSeconds extracts the wait duration from err.
|
||||
func SlowModeWaitSeconds(err error) (int, bool) {
|
||||
var wait SlowModeWaitError
|
||||
if !errors.As(err, &wait) {
|
||||
return 0, false
|
||||
}
|
||||
if wait.Seconds <= 0 {
|
||||
return 1, true
|
||||
}
|
||||
return wait.Seconds, true
|
||||
}
|
||||
65
internal/domain/contact.go
Normal file
65
internal/domain/contact.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package domain
|
||||
|
||||
// Contact 是当前账号通讯录中的一个已注册联系人。
|
||||
//
|
||||
// FirstName/LastName/Phone/Note 是 owner 视角数据:同一个 target user 在不同
|
||||
// owner 的通讯录里可以有不同备注,这些字段不回写 users 全局资料。
|
||||
type Contact struct {
|
||||
User User
|
||||
FirstName string
|
||||
LastName string
|
||||
Phone string
|
||||
Note string
|
||||
NoteEntities []MessageEntity
|
||||
Mutual bool
|
||||
}
|
||||
|
||||
// ContactList 是通讯录查询结果。
|
||||
type ContactList struct {
|
||||
Contacts []Contact
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// ContactInput 描述一次 owner 视角联系人写入。
|
||||
type ContactInput struct {
|
||||
ContactUserID int64
|
||||
ClientID int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Note string
|
||||
NoteEntities []MessageEntity
|
||||
AddPhonePrivacyException bool
|
||||
}
|
||||
|
||||
// ImportedContact 是 contacts.importContacts 成功导入项。
|
||||
type ImportedContact struct {
|
||||
UserID int64
|
||||
ClientID int64
|
||||
}
|
||||
|
||||
// ImportContactsResult 是 contacts.importContacts 的业务结果。
|
||||
type ImportContactsResult struct {
|
||||
Imported []ImportedContact
|
||||
Contacts []Contact
|
||||
RetryContacts []int64
|
||||
}
|
||||
|
||||
// UserSearchResult 是 contacts.search 的业务结果。
|
||||
// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。
|
||||
// MyChannelResults 放当前账号已加入的公开 channel/supergroup;ChannelResults 放其他公开命中。
|
||||
type UserSearchResult struct {
|
||||
MyResults []User
|
||||
Results []User
|
||||
MyChannelResults []Channel
|
||||
ChannelResults []Channel
|
||||
}
|
||||
|
||||
// PeerSettings 是当前 owner 看某个 peer 的可操作状态。
|
||||
type PeerSettings struct {
|
||||
AddContact bool
|
||||
BlockContact bool
|
||||
ShareContact bool
|
||||
NeedContactsException bool
|
||||
HiddenPeerSettingsBar bool
|
||||
}
|
||||
155
internal/domain/dialog.go
Normal file
155
internal/domain/dialog.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package domain
|
||||
|
||||
// PeerType 标识 dialog 所属 peer 类型。
|
||||
type PeerType string
|
||||
|
||||
const (
|
||||
PeerTypeUser PeerType = "user"
|
||||
PeerTypeChannel PeerType = "channel"
|
||||
)
|
||||
|
||||
const (
|
||||
// DialogMainFolderID 是 TDesktop 主会话列表 folder_id。
|
||||
DialogMainFolderID = 0
|
||||
// DialogArchiveFolderID 是 Telegram 约定的归档会话 folder_id。
|
||||
DialogArchiveFolderID = 1
|
||||
// DialogCustomFolderMinID 起才允许用户自定义 filter。
|
||||
DialogCustomFolderMinID = 2
|
||||
// MaxDialogFolders 限制单用户自定义 filter 数量,避免无界配置拖垮启动同步。
|
||||
MaxDialogFolders = 100
|
||||
// MaxDialogFolderPeers 限制单 filter 中 include/exclude/pinned peer 数。
|
||||
MaxDialogFolderPeers = 100
|
||||
// MaxDialogFolderTitleRunes 对齐 Telegram folder title 的短标题语义。
|
||||
MaxDialogFolderTitleRunes = 64
|
||||
// MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work.
|
||||
MaxDialogDraftsPerUser = 1000
|
||||
)
|
||||
|
||||
// Peer 是业务层 peer 值对象,不依赖 TL 类型。
|
||||
type Peer struct {
|
||||
Type PeerType
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Dialog 是账号的一条会话摘要。
|
||||
type Dialog struct {
|
||||
Peer Peer
|
||||
ChannelLeft bool
|
||||
FolderID int
|
||||
TopMessage int
|
||||
TopMessageDate int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
PeerSettingsBarHidden bool
|
||||
Draft *DialogDraft
|
||||
}
|
||||
|
||||
// DialogDraftWebPage stores a draft link preview without depending on TL input media types.
|
||||
type DialogDraftWebPage struct {
|
||||
URL string
|
||||
ForceLargeMedia bool
|
||||
ForceSmallMedia bool
|
||||
Optional bool
|
||||
}
|
||||
|
||||
// DialogDraft is a cloud draft for one peer/topic, expressed only in domain types.
|
||||
type DialogDraft struct {
|
||||
Peer Peer
|
||||
TopMessageID int
|
||||
Date int
|
||||
NoWebpage bool
|
||||
InvertMedia bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
ReplyTo *MessageReply
|
||||
WebPage *DialogDraftWebPage
|
||||
Effect int64
|
||||
}
|
||||
|
||||
// Empty reports whether this draft should clear the cloud draft slot.
|
||||
func (d DialogDraft) Empty() bool {
|
||||
replyOnlyTopic := d.ReplyTo != nil && d.ReplyTo.MessageID == 0 && d.ReplyTo.TopMessageID > 0
|
||||
return !d.NoWebpage &&
|
||||
!d.InvertMedia &&
|
||||
d.Message == "" &&
|
||||
len(d.Entities) == 0 &&
|
||||
(d.ReplyTo == nil || replyOnlyTopic) &&
|
||||
d.WebPage == nil &&
|
||||
d.Effect == 0
|
||||
}
|
||||
|
||||
// DialogList 是 dialogs 查询结果。
|
||||
type DialogList struct {
|
||||
Dialogs []Dialog
|
||||
Messages []Message
|
||||
ChannelMessages []ChannelMessage
|
||||
Users []User
|
||||
Channels []Channel
|
||||
State UpdateState
|
||||
Hash int64
|
||||
Count int
|
||||
}
|
||||
|
||||
// DialogFilter 是会话列表查询条件。
|
||||
type DialogFilter struct {
|
||||
PinnedOnly bool
|
||||
ExcludePinned bool
|
||||
HasFolderID bool
|
||||
FolderID int
|
||||
Folder *DialogFolder
|
||||
OffsetDate int
|
||||
OffsetID int
|
||||
HasOffsetPeer bool
|
||||
OffsetPeer Peer
|
||||
Limit int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// DialogFolderPeer 是 folder/filter 规则中的 peer,保留 access_hash 供 RPC 层回写 InputPeer。
|
||||
type DialogFolderPeer struct {
|
||||
Peer Peer
|
||||
AccessHash int64
|
||||
}
|
||||
|
||||
// DialogFolder 是用户自定义会话分组规则。它只表达业务含义,不依赖 TL 生成类型。
|
||||
type DialogFolder struct {
|
||||
ID int
|
||||
Contacts bool
|
||||
NonContacts bool
|
||||
Groups bool
|
||||
Broadcasts bool
|
||||
Bots bool
|
||||
ExcludeMuted bool
|
||||
ExcludeRead bool
|
||||
ExcludeArchived bool
|
||||
TitleNoanimate bool
|
||||
Title string
|
||||
TitleEntities []MessageEntity
|
||||
Emoticon string
|
||||
HasEmoticon bool
|
||||
Color int
|
||||
HasColor bool
|
||||
PinnedPeers []DialogFolderPeer
|
||||
IncludePeers []DialogFolderPeer
|
||||
ExcludePeers []DialogFolderPeer
|
||||
IsChatlist bool
|
||||
}
|
||||
|
||||
// DialogFolderList 是 messages.getDialogFilters 的业务响应。
|
||||
type DialogFolderList struct {
|
||||
TagsEnabled bool
|
||||
Folders []DialogFolder
|
||||
}
|
||||
|
||||
// FolderPeerUpdate 描述 folders.editPeerFolders 的单个归档/还原变更。
|
||||
type FolderPeerUpdate struct {
|
||||
Peer Peer
|
||||
FolderID int
|
||||
}
|
||||
4
internal/domain/doc.go
Normal file
4
internal/domain/doc.go
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Package domain 存放业务实体与值对象(User、Peer、Dialog、Message、MessageID 等)。
|
||||
//
|
||||
// 铁律:本包禁止依赖 gotd/td/tg 等协议层类型;TL 类型只允许出现在 RPC/MTProto 边界。
|
||||
package domain
|
||||
30
internal/domain/help.go
Normal file
30
internal/domain/help.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package domain
|
||||
|
||||
// AppConfig 是客户端应用配置。
|
||||
type AppConfig struct {
|
||||
Client string
|
||||
Hash int
|
||||
JSON []byte
|
||||
}
|
||||
|
||||
// CountryCode 是一个国家的电话区号规则。
|
||||
type CountryCode struct {
|
||||
CountryCode string
|
||||
Prefixes []string
|
||||
Patterns []string
|
||||
}
|
||||
|
||||
// Country 是登录页国家/区号选择项。
|
||||
type Country struct {
|
||||
ISO2 string
|
||||
DefaultName string
|
||||
Name string
|
||||
Hidden bool
|
||||
CountryCodes []CountryCode
|
||||
}
|
||||
|
||||
// CountriesList 是 help.getCountriesList 查询结果。
|
||||
type CountriesList struct {
|
||||
Hash int
|
||||
Countries []Country
|
||||
}
|
||||
24
internal/domain/langpack.go
Normal file
24
internal/domain/langpack.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package domain
|
||||
|
||||
// LangPack 是一份客户端语言包的查询结果。
|
||||
type LangPack struct {
|
||||
LangPack string
|
||||
LangCode string
|
||||
FromVersion int
|
||||
Version int
|
||||
Strings []LangPackString
|
||||
}
|
||||
|
||||
// LangPackString 是语言包中的一个普通或复数形式字符串。
|
||||
type LangPackString struct {
|
||||
Key string
|
||||
Value string
|
||||
Pluralized bool
|
||||
ZeroValue string
|
||||
OneValue string
|
||||
TwoValue string
|
||||
FewValue string
|
||||
ManyValue string
|
||||
OtherValue string
|
||||
Deleted bool
|
||||
}
|
||||
331
internal/domain/media.go
Normal file
331
internal/domain/media.go
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
package domain
|
||||
|
||||
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。
|
||||
// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。
|
||||
//
|
||||
// 字段带 json tag 是为了 store 层可直接 json.Marshal 落 JSONB(消息 media 快照、
|
||||
// 文档/照片元数据)。它们是协议无关的纯数据,不是 tg 生成类型。
|
||||
|
||||
// MediaBackend 标识 blob 字节实际存放后端。第一阶段只有本地磁盘。
|
||||
type MediaBackend string
|
||||
|
||||
const (
|
||||
// MediaBackendLocalFS 表示 blob 字节存在本地磁盘(object_key 为相对路径)。
|
||||
MediaBackendLocalFS MediaBackend = "localfs"
|
||||
)
|
||||
|
||||
// FileBlob 是一个可下载的二进制对象的索引项:location_key → 后端/对象键/大小/mime。
|
||||
// 真正的字节由 blob backend 按 ObjectKey 读写;本结构只描述定位与元数据。
|
||||
type FileBlob struct {
|
||||
// LocationKey 是稳定的逻辑定位键,由 getFile 的 InputFileLocation 推导:
|
||||
// doc:<id> 文档主体
|
||||
// doc:<id>:<type> 文档缩略图(PhotoSize type)
|
||||
// photo:<id>:<type> 照片某尺寸
|
||||
LocationKey string `json:"location_key"`
|
||||
Backend MediaBackend `json:"backend"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 []byte `json:"sha256,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
}
|
||||
|
||||
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG,组装后清理)。
|
||||
type UploadPart struct {
|
||||
OwnerUserID int64
|
||||
FileID int64
|
||||
Part int
|
||||
TotalParts int // big file 已知总数;small file 为 0
|
||||
Big bool
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。
|
||||
// rpc 层从 tg.InputFile/InputFileBig 转换得到;files 服务据此组装 blob。
|
||||
type UploadedFileRef struct {
|
||||
OwnerUserID int64
|
||||
FileID int64
|
||||
Parts int
|
||||
Name string
|
||||
Big bool
|
||||
MD5 string // small file 客户端 md5_checksum(hex),可校验;big file 为空
|
||||
}
|
||||
|
||||
// DocumentSpec 描述从上传文件创建 Document 的元数据(来自 InputMediaUploadedDocument)。
|
||||
type DocumentSpec struct {
|
||||
MimeType string
|
||||
Attributes []DocumentAttribute
|
||||
Thumb *UploadedFileRef // 可选缩略图上传,生成 doc:<id>:m
|
||||
ForceFile bool
|
||||
}
|
||||
|
||||
// FileDownloadRequest 是 upload.getFile 解析后的下载请求;
|
||||
// LocationKey 由 rpc 层从 tg.InputFileLocation 推导(doc:<id> / photo:<id>:<type> 等)。
|
||||
type FileDownloadRequest struct {
|
||||
LocationKey string
|
||||
Offset int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// FileChunk 是 upload.getFile 返回的一段内容。
|
||||
type FileChunk struct {
|
||||
Bytes []byte
|
||||
MimeType string
|
||||
Total int64
|
||||
}
|
||||
|
||||
// PhotoSizeKind 标识 PhotoSize 的 TL 变体。
|
||||
type PhotoSizeKind string
|
||||
|
||||
const (
|
||||
// PhotoSizeKindDefault → photoSize(可下载,type/w/h/size)。
|
||||
PhotoSizeKindDefault PhotoSizeKind = "size"
|
||||
// PhotoSizeKindStripped → photoStrippedSize(内联字节,秒开模糊图)。
|
||||
PhotoSizeKindStripped PhotoSizeKind = "stripped"
|
||||
// PhotoSizeKindCached → photoCachedSize(内联字节 + w/h)。
|
||||
PhotoSizeKindCached PhotoSizeKind = "cached"
|
||||
// PhotoSizeKindPath → photoPathSize(内联 svg path 字节,矢量占位)。
|
||||
PhotoSizeKindPath PhotoSizeKind = "path"
|
||||
// PhotoSizeKindProgressive → photoSizeProgressive(渐进式 jpeg 多段大小)。
|
||||
PhotoSizeKindProgressive PhotoSizeKind = "progressive"
|
||||
)
|
||||
|
||||
// PhotoSize 描述照片/缩略图的一种渲染尺寸。
|
||||
type PhotoSize struct {
|
||||
Kind PhotoSizeKind `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
W int `json:"w,omitempty"`
|
||||
H int `json:"h,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容
|
||||
Sizes []int `json:"sizes,omitempty"` // progressive
|
||||
}
|
||||
|
||||
// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。
|
||||
func (s PhotoSize) Downloadable() bool {
|
||||
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive
|
||||
}
|
||||
|
||||
// DocumentAttributeKind 标识 TL DocumentAttribute 变体。
|
||||
type DocumentAttributeKind string
|
||||
|
||||
const (
|
||||
DocAttrImageSize DocumentAttributeKind = "image_size"
|
||||
DocAttrAnimated DocumentAttributeKind = "animated"
|
||||
DocAttrSticker DocumentAttributeKind = "sticker"
|
||||
DocAttrVideo DocumentAttributeKind = "video"
|
||||
DocAttrAudio DocumentAttributeKind = "audio"
|
||||
DocAttrFilename DocumentAttributeKind = "filename"
|
||||
DocAttrCustomEmoji DocumentAttributeKind = "custom_emoji"
|
||||
)
|
||||
|
||||
// DocumentAttribute 是主路径用到的 TL DocumentAttribute 变体的并集。
|
||||
type DocumentAttribute struct {
|
||||
Kind DocumentAttributeKind `json:"kind"`
|
||||
|
||||
// image_size / video / sticker box
|
||||
W int `json:"w,omitempty"`
|
||||
H int `json:"h,omitempty"`
|
||||
|
||||
// sticker / custom_emoji
|
||||
Alt string `json:"alt,omitempty"`
|
||||
Mask bool `json:"mask,omitempty"`
|
||||
StickerSetID int64 `json:"sticker_set_id,omitempty"`
|
||||
StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"`
|
||||
Free bool `json:"free,omitempty"` // custom_emoji
|
||||
TextColor bool `json:"text_color,omitempty"` // custom_emoji
|
||||
|
||||
// video
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
RoundMessage bool `json:"round_message,omitempty"`
|
||||
SupportsStreaming bool `json:"supports_streaming,omitempty"`
|
||||
|
||||
// audio
|
||||
AudioDuration int `json:"audio_duration,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Performer string `json:"performer,omitempty"`
|
||||
Waveform []byte `json:"waveform,omitempty"`
|
||||
|
||||
// filename
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
}
|
||||
|
||||
// Document 是已存储的 Telegram 文档(贴纸、gif、文件、视频、音频、自定义 emoji……)。
|
||||
type Document struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference []byte `json:"file_reference,omitempty"`
|
||||
Date int `json:"date,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
DCID int `json:"dc_id,omitempty"`
|
||||
Attributes []DocumentAttribute `json:"attributes,omitempty"`
|
||||
Thumbs []PhotoSize `json:"thumbs,omitempty"`
|
||||
}
|
||||
|
||||
// StickerSetRef 返回该文档归属的贴纸集引用(若有 sticker/custom_emoji 属性)。
|
||||
func (d Document) StickerSetRef() (id, accessHash int64, ok bool) {
|
||||
for _, attr := range d.Attributes {
|
||||
if attr.Kind == DocAttrSticker || attr.Kind == DocAttrCustomEmoji {
|
||||
if attr.StickerSetID != 0 {
|
||||
return attr.StickerSetID, attr.StickerSetAccessHash, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// Photo 是已存储的 Telegram 照片(头像或图片消息)。
|
||||
type Photo struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference []byte `json:"file_reference,omitempty"`
|
||||
Date int `json:"date,omitempty"`
|
||||
DCID int `json:"dc_id,omitempty"`
|
||||
HasStickers bool `json:"has_stickers,omitempty"`
|
||||
Sizes []PhotoSize `json:"sizes,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMediaKind 枚举消息可挂载的媒体载荷。
|
||||
type MessageMediaKind string
|
||||
|
||||
const (
|
||||
MessageMediaKindNone MessageMediaKind = ""
|
||||
MessageMediaKindPhoto MessageMediaKind = "photo"
|
||||
MessageMediaKindDocument MessageMediaKind = "document"
|
||||
)
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
type MessageMedia struct {
|
||||
Kind MessageMediaKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Round bool `json:"round,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty)。
|
||||
func (m *MessageMedia) IsZero() bool {
|
||||
return m == nil || m.Kind == MessageMediaKindNone
|
||||
}
|
||||
|
||||
// StickerPack 是 emoji→文档 id 的映射条目(messages.stickerSet.packs)。
|
||||
type StickerPack struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
DocumentIDs []int64 `json:"document_ids"`
|
||||
}
|
||||
|
||||
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
|
||||
type StickerSetKind string
|
||||
|
||||
const (
|
||||
StickerSetKindStickers StickerSetKind = "stickers"
|
||||
StickerSetKindEmoji StickerSetKind = "emoji"
|
||||
StickerSetKindMasks StickerSetKind = "masks"
|
||||
// StickerSetKindSystem 是 TDesktop 通过 InputStickerSetDice/AnimatedEmoji 等系统集请求的内置集。
|
||||
StickerSetKindSystem StickerSetKind = "system"
|
||||
)
|
||||
|
||||
// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。
|
||||
type StickerSet struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
ShortName string `json:"short_name"`
|
||||
Title string `json:"title"`
|
||||
Count int `json:"count"`
|
||||
Hash int `json:"hash"`
|
||||
Kind StickerSetKind `json:"set_kind"`
|
||||
Official bool `json:"official,omitempty"`
|
||||
Animated bool `json:"animated,omitempty"`
|
||||
Videos bool `json:"videos,omitempty"`
|
||||
Emojis bool `json:"emojis,omitempty"`
|
||||
Masks bool `json:"masks,omitempty"`
|
||||
Installed bool `json:"installed,omitempty"`
|
||||
Archived bool `json:"archived,omitempty"`
|
||||
InstalledDate int `json:"installed_date,omitempty"`
|
||||
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
|
||||
Thumbs []PhotoSize `json:"thumbs,omitempty"`
|
||||
ThumbDCID int `json:"thumb_dc_id,omitempty"`
|
||||
ThumbVersion int `json:"thumb_version,omitempty"`
|
||||
DocumentIDs []int64 `json:"document_ids,omitempty"`
|
||||
Packs []StickerPack `json:"packs,omitempty"`
|
||||
SortOrder int `json:"sort_order,omitempty"`
|
||||
// SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。
|
||||
SystemKey string `json:"system_key,omitempty"`
|
||||
}
|
||||
|
||||
// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile photo)。
|
||||
type ProfilePhotoRef struct {
|
||||
PhotoID int64
|
||||
DCID int
|
||||
Stripped []byte // photoStrippedSize 内联缩略图,可空
|
||||
}
|
||||
|
||||
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
|
||||
func StrippedFromSizes(sizes []PhotoSize) []byte {
|
||||
for _, s := range sizes {
|
||||
if s.Kind == PhotoSizeKindStripped {
|
||||
return s.Bytes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StickerSetRefKind 标识 InputStickerSet 的解析方式。
|
||||
type StickerSetRefKind string
|
||||
|
||||
const (
|
||||
StickerSetRefByID StickerSetRefKind = "id"
|
||||
StickerSetRefByShortName StickerSetRefKind = "short_name"
|
||||
StickerSetRefBySystem StickerSetRefKind = "system"
|
||||
)
|
||||
|
||||
// StickerSetRef 是 rpc 层从 tg.InputStickerSet 转换得到的贴纸集引用。
|
||||
type StickerSetRef struct {
|
||||
Kind StickerSetRefKind
|
||||
ID int64
|
||||
AccessHash int64
|
||||
ShortName string
|
||||
SystemKey string
|
||||
}
|
||||
|
||||
// AvailableReaction 描述 messages.getAvailableReactions 的一项(真实资源由文档 id 引用)。
|
||||
type AvailableReaction struct {
|
||||
Reaction string `json:"reaction"`
|
||||
Title string `json:"title"`
|
||||
Inactive bool `json:"inactive,omitempty"`
|
||||
Premium bool `json:"premium,omitempty"`
|
||||
StaticIconID int64 `json:"static_icon_id,omitempty"`
|
||||
AppearAnimationID int64 `json:"appear_animation_id,omitempty"`
|
||||
SelectAnimationID int64 `json:"select_animation_id,omitempty"`
|
||||
ActivateAnimationID int64 `json:"activate_animation_id,omitempty"`
|
||||
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
|
||||
AroundAnimationID int64 `json:"around_animation_id,omitempty"`
|
||||
CenterIconID int64 `json:"center_icon_id,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentIDs 收集该 reaction 引用的全部文档 id(去零去重,便于批量加载)。
|
||||
func (r AvailableReaction) DocumentIDs() []int64 {
|
||||
raw := []int64{
|
||||
r.StaticIconID, r.AppearAnimationID, r.SelectAnimationID,
|
||||
r.ActivateAnimationID, r.EffectAnimationID, r.AroundAnimationID, r.CenterIconID,
|
||||
}
|
||||
out := make([]int64, 0, len(raw))
|
||||
seen := make(map[int64]struct{}, len(raw))
|
||||
for _, id := range raw {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
16
internal/domain/media_errors.go
Normal file
16
internal/domain/media_errors.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go)。
|
||||
var (
|
||||
ErrFilePartInvalid = errors.New("file part invalid")
|
||||
ErrFilePartsInvalid = errors.New("file parts invalid")
|
||||
ErrFilePartTooBig = errors.New("file part too big")
|
||||
ErrFileReference = errors.New("file reference invalid")
|
||||
ErrMediaInvalid = errors.New("media invalid")
|
||||
ErrMediaEmpty = errors.New("media empty")
|
||||
ErrPhotoInvalid = errors.New("photo invalid")
|
||||
ErrStickersetInvalid = errors.New("stickerset invalid")
|
||||
ErrDocumentInvalid = errors.New("document invalid")
|
||||
)
|
||||
352
internal/domain/message.go
Normal file
352
internal/domain/message.go
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
package domain
|
||||
|
||||
// MessageEntityType 标识消息实体类型。
|
||||
type MessageEntityType string
|
||||
|
||||
const (
|
||||
MessageEntityBold MessageEntityType = "bold"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxMessageTextLength matches the first-stage text message limit exposed to Telegram clients.
|
||||
MaxMessageTextLength = 4096
|
||||
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
|
||||
MaxMessageReplyQuoteLength = 1024
|
||||
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id.
|
||||
MaxMessageReplyQuoteOffset = MaxMessageTextLength
|
||||
// MaxMessageEntityCount limits styled text entity vectors in message text and quotes.
|
||||
MaxMessageEntityCount = 256
|
||||
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
|
||||
MaxMessageBoxID = 1<<31 - 1
|
||||
// MaxDeleteMessageIDs 限制单次 deleteMessages/updateDeleteMessages 的 owner 视角 id 数量。
|
||||
// 大批量历史清理走 deleteHistory 分批推进,避免单个 RPC 构造超大数组或 durable payload。
|
||||
MaxDeleteMessageIDs = 1000
|
||||
// MaxGetMessageIDs 限制 getMessages / channels.getMessages 精确 ID 批量。
|
||||
MaxGetMessageIDs = 100
|
||||
// MaxDeleteHistoryBatch 限制单次 deleteHistory 实际清理的 message box 数量。
|
||||
// affectedHistory.Offset > 0 时客户端可继续调用,服务端不一次性 RETURNING 全历史。
|
||||
MaxDeleteHistoryBatch = 1000
|
||||
// MaxForwardMessageIDs 限制单次 forwardMessages 的 owner 视角 id 数量。
|
||||
MaxForwardMessageIDs = 100
|
||||
// MaxMessageHistoryAddOffset 限制 history/search 的 add_offset 绝对值。
|
||||
// TDesktop 正常只使用小窗口偏移;服务端必须拒绝把客户端传入的超大值变成 SQL OFFSET 或 slice capacity。
|
||||
MaxMessageHistoryAddOffset = 100
|
||||
)
|
||||
|
||||
// ClampMessageHistoryAddOffset bounds Telegram history/search add_offset to a small local window.
|
||||
func ClampMessageHistoryAddOffset(v int) int {
|
||||
if v > MaxMessageHistoryAddOffset {
|
||||
return MaxMessageHistoryAddOffset
|
||||
}
|
||||
if v < -MaxMessageHistoryAddOffset {
|
||||
return -MaxMessageHistoryAddOffset
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidateMessageReplyBounds validates reply fields that are independent of peer visibility.
|
||||
func ValidateMessageReplyBounds(reply *MessageReply) error {
|
||||
if reply == nil {
|
||||
return nil
|
||||
}
|
||||
if reply.MessageID < 0 || reply.MessageID > MaxMessageBoxID {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageEntity 是业务层消息实体,不依赖 TL 类型。
|
||||
type MessageEntity struct {
|
||||
Type MessageEntityType
|
||||
Offset int
|
||||
Length int
|
||||
}
|
||||
|
||||
// Message 是账号视角下的一条私聊消息。
|
||||
type Message struct {
|
||||
ID int // 当前 owner 视角下的 message box id,暴露给 Telegram 客户端。
|
||||
UID int64 // 共享私聊消息主体 id,不暴露给客户端。
|
||||
RandomID int64
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
From Peer
|
||||
Date int
|
||||
EditDate int
|
||||
Out bool
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
Body string
|
||||
Entities []MessageEntity
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Reactions *ChannelMessageReactions
|
||||
Pts int
|
||||
Media *MessageMedia
|
||||
}
|
||||
|
||||
// MessageReply describes a message reply/thread header without depending on TL types.
|
||||
type MessageReply struct {
|
||||
MessageID int
|
||||
Peer Peer
|
||||
TopMessageID int
|
||||
ForumTopic bool
|
||||
QuoteText string
|
||||
QuoteEntities []MessageEntity
|
||||
QuoteOffset int
|
||||
}
|
||||
|
||||
// MessageForward 描述一条转发消息的原始作者信息。
|
||||
type MessageForward struct {
|
||||
From Peer
|
||||
FromName string
|
||||
Date int
|
||||
ChannelPost int
|
||||
SavedFrom Peer
|
||||
SavedFromMsgID int
|
||||
}
|
||||
|
||||
// MessageList 是账号视角下的消息查询结果。
|
||||
type MessageList struct {
|
||||
Messages []Message
|
||||
Users []User
|
||||
Count int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// MessageFilter 描述历史/搜索查询条件。
|
||||
type MessageFilter struct {
|
||||
HasPeer bool
|
||||
Peer Peer
|
||||
Query string
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
NeedTotalCount bool
|
||||
}
|
||||
|
||||
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
|
||||
type SendPrivateTextRequest struct {
|
||||
SenderUserID int64
|
||||
RecipientUserID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
|
||||
type SendPrivateTextResult struct {
|
||||
SenderMessage Message
|
||||
RecipientMessage Message
|
||||
SenderEvent UpdateEvent
|
||||
RecipientEvent UpdateEvent
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message.
|
||||
type SetPrivateMessageReactionsRequest struct {
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
Reactions []MessageReaction
|
||||
Big bool
|
||||
AddToRecent bool
|
||||
Date int
|
||||
}
|
||||
|
||||
// PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids.
|
||||
type PrivateMessageReactionsRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
IDs []int
|
||||
}
|
||||
|
||||
// PrivateMessageReactionsResult describes private reaction updates in owner-visible boxes.
|
||||
type PrivateMessageReactionsResult struct {
|
||||
Messages []Message
|
||||
Reactions ChannelMessageReactions
|
||||
}
|
||||
|
||||
// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。
|
||||
type ForwardPrivateMessagesRequest struct {
|
||||
OwnerUserID int64
|
||||
FromPeer Peer
|
||||
ToUserID int64
|
||||
MessageIDs []int
|
||||
RandomIDs []int64
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
DropAuthor bool
|
||||
ReplyTo *MessageReply
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
|
||||
type ForwardPrivateMessagesResult struct {
|
||||
OwnerUserID int64
|
||||
SenderMessages []Message
|
||||
RecipientMessages []Message
|
||||
SenderEvents []UpdateEvent
|
||||
RecipientEvents []UpdateEvent
|
||||
Duplicates []bool
|
||||
}
|
||||
|
||||
// ReadHistoryRequest 是账号视角的 messages.readHistory 命令。
|
||||
type ReadHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// ReadHistoryResult 描述一次会话已读操作的业务结果。
|
||||
type ReadHistoryResult struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
StillUnreadCount int
|
||||
Changed bool
|
||||
InboxEvent UpdateEvent
|
||||
OutboxChanged bool
|
||||
OutboxUserID int64
|
||||
OutboxEvent UpdateEvent
|
||||
}
|
||||
|
||||
// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages.
|
||||
type ReadMessageContentsRequest struct {
|
||||
OwnerUserID int64
|
||||
IDs []int
|
||||
}
|
||||
|
||||
// ReadMessageContentsResult contains owner-visible message IDs that existed and can be synced.
|
||||
type ReadMessageContentsResult struct {
|
||||
OwnerUserID int64
|
||||
MessageIDs []int
|
||||
}
|
||||
|
||||
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。
|
||||
type OutboxReadDateRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
ID int
|
||||
}
|
||||
|
||||
// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。
|
||||
type EditMessageRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
ID int
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
EditDate int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。
|
||||
type EditedMessageForUser struct {
|
||||
UserID int64
|
||||
Message Message
|
||||
Event UpdateEvent
|
||||
}
|
||||
|
||||
// EditMessageResult 描述消息编辑后的 owner 维度结果。
|
||||
type EditMessageResult struct {
|
||||
OwnerUserID int64
|
||||
Edited []EditedMessageForUser
|
||||
}
|
||||
|
||||
// Self 返回当前请求账号的编辑结果。
|
||||
func (r EditMessageResult) Self() EditedMessageForUser {
|
||||
for _, item := range r.Edited {
|
||||
if item.UserID == r.OwnerUserID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return EditedMessageForUser{UserID: r.OwnerUserID}
|
||||
}
|
||||
|
||||
// Changed 表示本次编辑是否实际影响了任何 owner 视角。
|
||||
func (r EditMessageResult) Changed() bool {
|
||||
return len(r.Edited) > 0
|
||||
}
|
||||
|
||||
// DeleteMessagesRequest 是账号视角下按消息 ID 删除消息的命令。
|
||||
type DeleteMessagesRequest struct {
|
||||
OwnerUserID int64
|
||||
IDs []int
|
||||
Revoke bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。
|
||||
type DeleteHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
JustClear bool
|
||||
Revoke bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// DeletedMessagesForUser 描述一次删除对某个 owner 视角造成的影响。
|
||||
type DeletedMessagesForUser struct {
|
||||
UserID int64
|
||||
MessageIDs []int
|
||||
Event UpdateEvent
|
||||
}
|
||||
|
||||
// DeleteMessagesResult 描述消息删除后的 owner 维度结果。
|
||||
type DeleteMessagesResult struct {
|
||||
OwnerUserID int64
|
||||
Deleted []DeletedMessagesForUser
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Self 返回当前请求账号的删除结果。
|
||||
func (r DeleteMessagesResult) Self() DeletedMessagesForUser {
|
||||
for _, item := range r.Deleted {
|
||||
if item.UserID == r.OwnerUserID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return DeletedMessagesForUser{UserID: r.OwnerUserID}
|
||||
}
|
||||
|
||||
// Changed 表示本次删除是否实际影响了任何 owner 视角。
|
||||
func (r DeleteMessagesResult) Changed() bool {
|
||||
for _, item := range r.Deleted {
|
||||
if len(item.MessageIDs) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
12
internal/domain/message_errors.go
Normal file
12
internal/domain/message_errors.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrMessageIDInvalid = errors.New("message id invalid")
|
||||
ErrMessageAuthorRequired = errors.New("message author required")
|
||||
ErrMessageNotModified = errors.New("message not modified")
|
||||
ErrMessageNotReadYet = errors.New("message not read yet")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
)
|
||||
27
internal/domain/message_test.go
Normal file
27
internal/domain/message_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMessageReplyBoundsRejectsQuoteOffsetAsTextOffset(t *testing.T) {
|
||||
reply := &MessageReply{
|
||||
MessageID: 1,
|
||||
QuoteText: "hello",
|
||||
QuoteOffset: MaxMessageReplyQuoteOffset + 1,
|
||||
}
|
||||
if err := ValidateMessageReplyBounds(reply); !errors.Is(err, ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("ValidateMessageReplyBounds err = %v, want ErrReplyMessageIDInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageReplyBoundsAllowsForumTopicOnlyHeader(t *testing.T) {
|
||||
reply := &MessageReply{
|
||||
TopMessageID: 10,
|
||||
ForumTopic: true,
|
||||
}
|
||||
if err := ValidateMessageReplyBounds(reply); err != nil {
|
||||
t.Fatalf("ValidateMessageReplyBounds err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
19
internal/domain/system.go
Normal file
19
internal/domain/system.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package domain
|
||||
|
||||
const (
|
||||
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
||||
OfficialSystemUserID int64 = 777000
|
||||
)
|
||||
|
||||
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
|
||||
func OfficialSystemUser() User {
|
||||
return User{
|
||||
ID: OfficialSystemUserID,
|
||||
AccessHash: 6599886787491911851,
|
||||
Phone: "42777",
|
||||
FirstName: "Telegram",
|
||||
Username: "telegram",
|
||||
Verified: true,
|
||||
Support: true,
|
||||
}
|
||||
}
|
||||
11
internal/domain/temp_auth_key.go
Normal file
11
internal/domain/temp_auth_key.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package domain
|
||||
|
||||
// TempAuthKeyBinding 是 auth.bindTempAuthKey 的持久化记录。
|
||||
type TempAuthKeyBinding struct {
|
||||
TempAuthKeyID [8]byte
|
||||
PermAuthKeyID int64
|
||||
Nonce int64
|
||||
TempSessionID int64
|
||||
ExpiresAt int
|
||||
EncryptedMessage []byte
|
||||
}
|
||||
10
internal/domain/update.go
Normal file
10
internal/domain/update.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package domain
|
||||
|
||||
// UpdateState 是账号的 update 状态(pts/qts/seq/date)。
|
||||
// 第一阶段空账号为零值;真实状态机属第二阶段。
|
||||
type UpdateState struct {
|
||||
Pts int
|
||||
Qts int
|
||||
Date int
|
||||
Seq int
|
||||
}
|
||||
58
internal/domain/update_event.go
Normal file
58
internal/domain/update_event.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package domain
|
||||
|
||||
// UpdateEventType 标识 update 队列事件类型。
|
||||
type UpdateEventType string
|
||||
|
||||
const (
|
||||
UpdateEventNewMessage UpdateEventType = "new_message"
|
||||
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
|
||||
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
|
||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
||||
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
||||
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
||||
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
|
||||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
|
||||
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
||||
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
||||
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
||||
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
||||
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
||||
UpdateEventNoop UpdateEventType = "noop"
|
||||
)
|
||||
|
||||
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
|
||||
type UpdateEvent struct {
|
||||
UserID int64
|
||||
Type UpdateEventType
|
||||
Pts int
|
||||
PtsCount int
|
||||
Date int
|
||||
Message Message
|
||||
Peer Peer
|
||||
Peers []Peer
|
||||
Bool bool
|
||||
Settings PeerSettings
|
||||
MessageIDs []int
|
||||
MaxID int
|
||||
StillUnreadCount int
|
||||
Users []User
|
||||
Channels []Channel
|
||||
FilterID int
|
||||
DialogFilter *DialogFolder
|
||||
FilterOrder []int
|
||||
FolderPeers []FolderPeerUpdate
|
||||
TagsEnabled bool
|
||||
}
|
||||
|
||||
// UpdateDifference 是 updates.getDifference 的业务层结果。
|
||||
type UpdateDifference struct {
|
||||
State UpdateState
|
||||
Events []UpdateEvent
|
||||
// Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice,
|
||||
// 客户端据 State 继续翻页);false 表示已到当前连续末尾(updates.difference)。
|
||||
Partial bool
|
||||
}
|
||||
63
internal/domain/user.go
Normal file
63
internal/domain/user.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package domain
|
||||
|
||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||
//
|
||||
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
|
||||
// 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。
|
||||
const UserIDSequenceBase int64 = 1780243200
|
||||
|
||||
// User 是一个账号。第一阶段仅保留登录链路必须字段;
|
||||
// access_hash 为任何 InputUser 校验所必须,不可省。
|
||||
type User struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
About string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
Contact bool
|
||||
Mutual bool
|
||||
// Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。
|
||||
PhotoID int64
|
||||
PhotoDCID int
|
||||
PhotoStripped []byte
|
||||
LastSeenAt int
|
||||
Status UserStatus
|
||||
}
|
||||
|
||||
// UserStatusKind is a protocol-neutral account presence state.
|
||||
type UserStatusKind int
|
||||
|
||||
const (
|
||||
UserStatusUnknown UserStatusKind = iota
|
||||
UserStatusOnline
|
||||
UserStatusOffline
|
||||
UserStatusRecently
|
||||
UserStatusLastWeek
|
||||
UserStatusLastMonth
|
||||
UserStatusEmpty
|
||||
)
|
||||
|
||||
// UserStatus describes the currently visible presence state for a user.
|
||||
//
|
||||
// Expires and WasOnline are absolute Unix timestamps in seconds, matching
|
||||
// Telegram's UserStatus semantics without leaking tg.* into domain.
|
||||
type UserStatus struct {
|
||||
Kind UserStatusKind
|
||||
Expires int
|
||||
WasOnline int
|
||||
}
|
||||
|
||||
// UserProfileUpdate 描述 account.updateProfile 的可选字段更新。
|
||||
type UserProfileUpdate struct {
|
||||
FirstName string
|
||||
HasFirstName bool
|
||||
LastName string
|
||||
HasLastName bool
|
||||
About string
|
||||
HasAbout bool
|
||||
}
|
||||
12
internal/domain/user_errors.go
Normal file
12
internal/domain/user_errors.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUsernameInvalid = errors.New("username invalid")
|
||||
ErrUsernameOccupied = errors.New("username occupied")
|
||||
ErrUsernameNotOccupied = errors.New("username not occupied")
|
||||
ErrPhoneNotOccupied = errors.New("phone not occupied")
|
||||
ErrFirstNameInvalid = errors.New("first name invalid")
|
||||
ErrAboutTooLong = errors.New("about too long")
|
||||
)
|
||||
13
internal/loadtest/doc.go
Normal file
13
internal/loadtest/doc.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Package loadtest 承载 message 模块的压测基线。
|
||||
//
|
||||
// 这里只放 env-gated 的压测用例(真实 PostgreSQL + Redis),不被生产代码 import。
|
||||
// 目标与基线方法见 docs/message-module.md 的「Load Baseline」一节。
|
||||
//
|
||||
// 运行方式(PowerShell):
|
||||
//
|
||||
// $env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"
|
||||
// $env:TELESRV_TEST_REDIS_ADDR = "localhost:6399"
|
||||
// go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1
|
||||
//
|
||||
// 未设置上述两个环境变量时用例直接 Skip,因此对默认 `go test ./...` 无副作用。
|
||||
package loadtest
|
||||
478
internal/loadtest/send_load_test.go
Normal file
478
internal/loadtest/send_load_test.go
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
package loadtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
messageapp "telesrv/internal/app/messages"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/store/postgres"
|
||||
"telesrv/internal/store/redisstore"
|
||||
)
|
||||
|
||||
// 第一阶段单机 SLO 目标,来自 docs/message-module.md 的 Next Execution Plan。
|
||||
// 默认仅作为信息打印;设 TELESRV_LOAD_ENFORCE_SLO=1 时超标会 fail(用于回归门禁)。
|
||||
const (
|
||||
sloSendP99 = 150 * time.Millisecond
|
||||
sloDiffP99 = 100 * time.Millisecond
|
||||
sloThroughput = 200.0 // msg/s
|
||||
drainTimeout = 60 * time.Second
|
||||
diffSampleGoal = 500
|
||||
)
|
||||
|
||||
// TestMessageSendBaseline 用真实 PostgreSQL + Redis 压测私聊文本发送热路径,
|
||||
// 并发跑 outbox dispatcher 排空在线推送,最后采样 getDifference 读路径。
|
||||
//
|
||||
// 这是 closed-loop 饱和压测:concurrency 个 worker 各自不停发,直到发满 messages 条。
|
||||
// 吞吐 = messages / wallclock,延迟分位反映该并发下的饱和延迟。
|
||||
// 固定到达率(open-loop)的版本留作后续细化(见 docs/message-module.md)。
|
||||
func TestMessageSendBaseline(t *testing.T) {
|
||||
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
|
||||
redisAddr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if dsn == "" || redisAddr == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN and TELESRV_TEST_REDIS_ADDR to run message load baseline")
|
||||
}
|
||||
|
||||
// 默认用户池取较大值:用户太少会把写集中到少数 dialog/message_box 行造成行锁争用,
|
||||
// 拉高 send 尾延迟(这是小池假象,生产 20 万用户分散后争用极低)。
|
||||
users := envInt("TELESRV_LOAD_USERS", 1000)
|
||||
if users < 2 {
|
||||
users = 2
|
||||
}
|
||||
concurrency := envInt("TELESRV_LOAD_CONCURRENCY", 32)
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
totalMsgs := envInt("TELESRV_LOAD_MESSAGES", 5000)
|
||||
if totalMsgs < 1 {
|
||||
totalMsgs = 1
|
||||
}
|
||||
poolConns := envInt("TELESRV_LOAD_POOL_CONNS", 64)
|
||||
workers := envInt("TELESRV_OUTBOX_WORKERS", 8)
|
||||
outboxBatch := envInt("TELESRV_OUTBOX_BATCH", 100)
|
||||
outboxInterval := envDuration("TELESRV_OUTBOX_INTERVAL", 50*time.Millisecond)
|
||||
leaseTimeout := envDuration("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second)
|
||||
enforceSLO := os.Getenv("TELESRV_LOAD_ENFORCE_SLO") == "1"
|
||||
deferDispatch := os.Getenv("TELESRV_LOAD_DEFER_DISPATCH") == "1"
|
||||
|
||||
ctx := context.Background()
|
||||
if err := postgres.Migrate(dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := postgres.Open(ctx, dsn, postgres.WithMaxConns(poolConns))
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
rdb, err := redisstore.Open(ctx, redisAddr, os.Getenv("TELESRV_TEST_REDIS_PASSWORD"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open redis: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rdb.Close() })
|
||||
|
||||
// 装配与 main.go 一致的消息热路径:Redis 分配器 + PG 消息存储 + transactional outbox。
|
||||
userStore := postgres.NewUserStore(pool)
|
||||
updateEventStore := postgres.NewUpdateEventStore(pool)
|
||||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(leaseTimeout))
|
||||
dialogStore := postgres.NewDialogStore(pool)
|
||||
ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore)
|
||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||
messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator))
|
||||
svc := messageapp.NewService(messageStore, dialogStore)
|
||||
|
||||
// 创建独立的测试用户池;用随机 salt 隔离历史残留,结束按 FK 依赖序清理。
|
||||
ids := seedUsers(t, ctx, userStore, users)
|
||||
t.Cleanup(func() { cleanup(t, pool, rdb, ids) })
|
||||
|
||||
// 在线推送 binder 用真实 SessionManager(零连接),PushToUserExceptSession 返回 0,
|
||||
// 让 outbox 走完整 claim→ListAfter→MarkDelivered 的 PG 往返,测排空而非网络 fanout。
|
||||
binder := mtprotoedge.NewSessionManager(zap.NewNop())
|
||||
metrics := &loadMetrics{}
|
||||
dispatcher := rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, binder, zap.NewNop(),
|
||||
rpc.WithOutboxWorkers(workers),
|
||||
rpc.WithOutboxBatch(outboxBatch),
|
||||
rpc.WithOutboxInterval(outboxInterval),
|
||||
rpc.WithOutboxMetrics(metrics),
|
||||
)
|
||||
dispCtx, stopDispatcher := context.WithCancel(ctx)
|
||||
dispDone := make(chan struct{})
|
||||
startDispatcher := func() {
|
||||
go func() {
|
||||
dispatcher.Run(dispCtx)
|
||||
close(dispDone)
|
||||
}()
|
||||
}
|
||||
// deferDispatch=1 时先不投递,让发送把积压攒满,再在发送结束后启动 dispatcher,
|
||||
// 以隔离测量「纯排空上限」(否则 dispatcher 实时跟上发送、积压近 0 量不出天花板)。
|
||||
if !deferDispatch {
|
||||
startDispatcher()
|
||||
}
|
||||
|
||||
// 后台采样 outbox 积压(pending+dispatching),记录运行期峰值。
|
||||
var maxBacklog atomic.Int64
|
||||
sampleCtx, stopSampler := context.WithCancel(ctx)
|
||||
sampleDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(sampleDone)
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-sampleCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if n := backlog(ctx, pool, ids); n > maxBacklog.Load() {
|
||||
maxBacklog.Store(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 随机 RandomID 基址,保证 (sender,random_id) 幂等唯一且跨重跑不撞。
|
||||
randBase := int64(randomUint64(t) & 0x7fff_ffff_ffff)
|
||||
nowUnix := int(time.Now().Unix())
|
||||
body := "telesrv load baseline message body"
|
||||
|
||||
perWorkerLat := make([][]time.Duration, concurrency)
|
||||
var sent, dup, sendErr atomic.Int64
|
||||
var counter atomic.Int64
|
||||
|
||||
// 预热:先并发发一批不计时的消息,热连接池(MinConns→MaxConns)与 PG plan 缓存,
|
||||
// 让后续计时窗口反映稳态而非冷启动尾延迟(池过冷时首批 32 并发会挤少量连接)。
|
||||
warmup := envInt("TELESRV_LOAD_WARMUP", min(users*10, 1000))
|
||||
if warmup > 0 {
|
||||
var wwg sync.WaitGroup
|
||||
for w := 0; w < concurrency; w++ {
|
||||
wwg.Add(1)
|
||||
go func() {
|
||||
defer wwg.Done()
|
||||
for {
|
||||
n := counter.Add(1)
|
||||
if n > int64(warmup) {
|
||||
return
|
||||
}
|
||||
sid := ids[(n-1)%int64(users)]
|
||||
rid := ids[n%int64(users)]
|
||||
_, _ = svc.SendPrivateText(ctx, sid, domain.SendPrivateTextRequest{
|
||||
SenderUserID: sid,
|
||||
RecipientUserID: rid,
|
||||
RandomID: randBase + n,
|
||||
Message: body,
|
||||
Date: nowUnix,
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wwg.Wait()
|
||||
counter.Store(int64(warmup))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < concurrency; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
lat := make([]time.Duration, 0, totalMsgs/concurrency+1)
|
||||
for {
|
||||
n := counter.Add(1)
|
||||
if n > int64(warmup+totalMsgs) {
|
||||
break
|
||||
}
|
||||
senderID := ids[(n-1)%int64(users)]
|
||||
recipientID := ids[n%int64(users)]
|
||||
req := domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: randBase + n,
|
||||
Message: body,
|
||||
Date: nowUnix,
|
||||
}
|
||||
t0 := time.Now()
|
||||
res, err := svc.SendPrivateText(ctx, senderID, req)
|
||||
lat = append(lat, time.Since(t0))
|
||||
if err != nil {
|
||||
sendErr.Add(1)
|
||||
continue
|
||||
}
|
||||
if res.Duplicate {
|
||||
dup.Add(1)
|
||||
}
|
||||
sent.Add(1)
|
||||
}
|
||||
perWorkerLat[w] = lat
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
sendWall := time.Since(start)
|
||||
deliveredAtSendEnd := metrics.delivered.Load()
|
||||
if deferDispatch {
|
||||
// 发送已把全部积压攒满,此刻才启动 dispatcher:drain 阶段即纯排空,drainRate 反映排空上限。
|
||||
startDispatcher()
|
||||
}
|
||||
|
||||
// 等 outbox 排空(积压回 0),记录排空耗时,再停采样和 dispatcher。
|
||||
// 用「发送结束后」单独排空的速率隔离 dispatcher 吞吐,去掉发送期对 PG 的争用。
|
||||
drainStart := time.Now()
|
||||
drained := waitDrain(ctx, pool, ids, drainTimeout)
|
||||
drainWall := time.Since(drainStart)
|
||||
drainRate := float64(metrics.delivered.Load()-deliveredAtSendEnd) / drainWall.Seconds()
|
||||
stopSampler()
|
||||
<-sampleDone
|
||||
stopDispatcher()
|
||||
<-dispDone
|
||||
|
||||
// 合并发送延迟样本并排序。
|
||||
latencies := make([]time.Duration, 0, totalMsgs)
|
||||
for _, l := range perWorkerLat {
|
||||
latencies = append(latencies, l...)
|
||||
}
|
||||
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||
|
||||
// 采样 getDifference 读路径(ListAfter 从 pts=0 拉账号事件)。
|
||||
diffLat := sampleGetDifference(t, ctx, updateEventStore, ids)
|
||||
sort.Slice(diffLat, func(i, j int) bool { return diffLat[i] < diffLat[j] })
|
||||
|
||||
okSent := sent.Load()
|
||||
throughput := float64(okSent) / sendWall.Seconds()
|
||||
sendP99 := percentile(latencies, 99)
|
||||
diffP99 := percentile(diffLat, 99)
|
||||
|
||||
t.Logf("==== message module load baseline ====")
|
||||
t.Logf("config: users=%d concurrency=%d messages=%d pool=%d outbox(workers=%d batch=%d interval=%s lease=%s)",
|
||||
users, concurrency, totalMsgs, poolConns, workers, outboxBatch, outboxInterval, leaseTimeout)
|
||||
t.Logf("send: %d ok, %d dup, %d err in %s -> %.0f msg/s",
|
||||
okSent, dup.Load(), sendErr.Load(), sendWall.Round(time.Millisecond), throughput)
|
||||
t.Logf("send.lat p50=%s p90=%s p99=%s max=%s",
|
||||
percentile(latencies, 50).Round(time.Microsecond),
|
||||
percentile(latencies, 90).Round(time.Microsecond),
|
||||
sendP99.Round(time.Microsecond),
|
||||
percentile(latencies, 100).Round(time.Microsecond))
|
||||
t.Logf("outbox: delivered=%d failed=%d claimed=%d maxBacklog=%d drain=%s drainRate=%.0f rows/s drained=%v",
|
||||
metrics.delivered.Load(), metrics.failed.Load(), metrics.claimed.Load(),
|
||||
maxBacklog.Load(), drainWall.Round(time.Millisecond), drainRate, drained)
|
||||
t.Logf("getDiff: samples=%d p50=%s p90=%s p99=%s max=%s",
|
||||
len(diffLat),
|
||||
percentile(diffLat, 50).Round(time.Microsecond),
|
||||
percentile(diffLat, 90).Round(time.Microsecond),
|
||||
diffP99.Round(time.Microsecond),
|
||||
percentile(diffLat, 100).Round(time.Microsecond))
|
||||
t.Logf("SLO: send.p99 %s(<%s) %s | getDiff.p99 %s(<%s) %s | throughput %.0f(>=%.0f) %s",
|
||||
sendP99.Round(time.Millisecond), sloSendP99, pass(sendP99 < sloSendP99),
|
||||
diffP99.Round(time.Millisecond), sloDiffP99, pass(diffP99 < sloDiffP99),
|
||||
throughput, sloThroughput, pass(throughput >= sloThroughput))
|
||||
t.Logf("=======================================")
|
||||
|
||||
// 正确性硬断言:发送不应出错、不应有意外重复、outbox 必须排空且无终态失败。
|
||||
if sendErr.Load() != 0 {
|
||||
t.Fatalf("send errors = %d, want 0", sendErr.Load())
|
||||
}
|
||||
if dup.Load() != 0 {
|
||||
t.Fatalf("duplicates = %d, want 0 (random_id 应唯一)", dup.Load())
|
||||
}
|
||||
if okSent != int64(totalMsgs) {
|
||||
t.Fatalf("sent ok = %d, want %d", okSent, totalMsgs)
|
||||
}
|
||||
if !drained {
|
||||
t.Fatalf("outbox 未在 %s 内排空,残留积压 %d", drainTimeout, backlog(ctx, pool, ids))
|
||||
}
|
||||
if metrics.failed.Load() != 0 {
|
||||
t.Fatalf("outbox failed = %d, want 0", metrics.failed.Load())
|
||||
}
|
||||
|
||||
// 性能 SLO:默认信息化,门禁模式(TELESRV_LOAD_ENFORCE_SLO=1)才硬失败。
|
||||
if enforceSLO {
|
||||
if sendP99 >= sloSendP99 {
|
||||
t.Errorf("send p99 %s >= SLO %s", sendP99, sloSendP99)
|
||||
}
|
||||
if diffP99 >= sloDiffP99 {
|
||||
t.Errorf("getDifference p99 %s >= SLO %s", diffP99, sloDiffP99)
|
||||
}
|
||||
if throughput < sloThroughput {
|
||||
t.Errorf("throughput %.0f msg/s < SLO %.0f msg/s", throughput, sloThroughput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadMetrics 实现 rpc.Metrics,统计 outbox claim/deliver/fail。
|
||||
type loadMetrics struct {
|
||||
claimed atomic.Int64
|
||||
delivered atomic.Int64
|
||||
failed atomic.Int64
|
||||
}
|
||||
|
||||
func (m *loadMetrics) MessageSend(time.Duration, bool, error) {}
|
||||
func (m *loadMetrics) MessageRateLimited(int) {}
|
||||
func (m *loadMetrics) OutboxClaimed(n int) { m.claimed.Add(int64(n)) }
|
||||
func (m *loadMetrics) OutboxDelivered(time.Duration) { m.delivered.Add(1) }
|
||||
func (m *loadMetrics) OutboxFailed(error) { m.failed.Add(1) }
|
||||
|
||||
func seedUsers(t *testing.T, ctx context.Context, store *postgres.UserStore, n int) []int64 {
|
||||
t.Helper()
|
||||
salt := randomUint64(t) % 1_000_000
|
||||
ids := make([]int64, n)
|
||||
errs := make([]error, n)
|
||||
sem := make(chan struct{}, 16)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
u, err := store.Create(ctx, domain.User{
|
||||
AccessHash: int64(i + 1),
|
||||
Phone: fmt.Sprintf("+1555%06d%05d", salt, i),
|
||||
FirstName: fmt.Sprintf("Load%05d", i),
|
||||
})
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
ids[i] = u.ID
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("create load user %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// cleanup 按 FK 依赖序删除测试数据:outbox→events→boxes→private_messages→dialogs→users,
|
||||
// 再清 Redis pts/box_id 计数。message_boxes.from_user_id 为 ON DELETE RESTRICT,必须先删盒子。
|
||||
// cleanup 在断言之后运行,出错只告警不影响已得结果。
|
||||
func cleanup(t *testing.T, pool *pgxpool.Pool, rdb *redis.Client, ids []int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
stmts := []string{
|
||||
"DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])",
|
||||
"DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])",
|
||||
"DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])",
|
||||
"DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])",
|
||||
"DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])",
|
||||
"DELETE FROM users WHERE id = ANY($1::bigint[])",
|
||||
}
|
||||
for _, sql := range stmts {
|
||||
if _, err := pool.Exec(ctx, sql, ids); err != nil {
|
||||
t.Logf("cleanup %q: %v", sql, err)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(ids)*2)
|
||||
for _, id := range ids {
|
||||
keys = append(keys,
|
||||
fmt.Sprintf("counter:pts:{%d}", id),
|
||||
fmt.Sprintf("counter:box_id:{%d}", id),
|
||||
)
|
||||
}
|
||||
if err := rdb.Del(ctx, keys...).Err(); err != nil {
|
||||
t.Logf("cleanup redis counters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// backlog 返回测试用户集当前未投递(pending+dispatching)的 outbox 行数。
|
||||
func backlog(ctx context.Context, pool *pgxpool.Pool, ids []int64) int64 {
|
||||
var n int64
|
||||
if err := pool.QueryRow(ctx,
|
||||
"SELECT count(*) FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[]) AND status IN ('pending','dispatching')",
|
||||
ids,
|
||||
).Scan(&n); err != nil {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// waitDrain 轮询 backlog 直到归零或超时,返回是否排空。
|
||||
func waitDrain(ctx context.Context, pool *pgxpool.Pool, ids []int64, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if backlog(ctx, pool, ids) == 0 {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleGetDifference(t *testing.T, ctx context.Context, events *postgres.UpdateEventStore, ids []int64) []time.Duration {
|
||||
t.Helper()
|
||||
out := make([]time.Duration, 0, diffSampleGoal)
|
||||
for i := 0; len(out) < diffSampleGoal; i++ {
|
||||
id := ids[i%len(ids)]
|
||||
t0 := time.Now()
|
||||
if _, err := events.ListAfter(ctx, id, 0, 100); err != nil {
|
||||
t.Fatalf("getDifference ListAfter user %d: %v", id, err)
|
||||
}
|
||||
out = append(out, time.Since(t0))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func percentile(sorted []time.Duration, p float64) time.Duration {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(math.Ceil(p/100*float64(len(sorted)))) - 1
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sorted) {
|
||||
idx = len(sorted) - 1
|
||||
}
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
func pass(ok bool) string {
|
||||
if ok {
|
||||
return "PASS"
|
||||
}
|
||||
return "WARN"
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envDuration(key string, def time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func randomUint64(t *testing.T) uint64 {
|
||||
t.Helper()
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return binary.LittleEndian.Uint64(b[:])
|
||||
}
|
||||
114
internal/mtprotoedge/conn.go
Normal file
114
internal/mtprotoedge/conn.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/transport"
|
||||
)
|
||||
|
||||
// Conn 是一个已识别 session 的客户端连接,持有向其加密发送消息所需的全部上下文。
|
||||
// 由 SessionManager 管理,供请求响应与主动 push 共用。
|
||||
//
|
||||
// Send 并发安全:所有出站消息先进 per-Conn outbound actor,由它串行分配 msg_id/seq_no、
|
||||
// 加密并写 transport,避免高并发 RPC 响应与 push 交错造成 MTProto 顺序错误。
|
||||
type outboundWriter interface {
|
||||
Send(context.Context, *bin.Buffer) error
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
transport transport.Conn
|
||||
writer outboundWriter
|
||||
cipher crypto.Cipher
|
||||
msgID *proto.MessageIDGen
|
||||
writeTimeout time.Duration
|
||||
metrics Metrics
|
||||
|
||||
authKeyID [8]byte
|
||||
sessionID int64
|
||||
salt int64
|
||||
key crypto.AuthKey
|
||||
|
||||
outbound chan outboundOp
|
||||
outboundControl chan outboundOp
|
||||
outboundStop chan struct{}
|
||||
outboundDone chan struct{}
|
||||
outboundClose sync.Once
|
||||
|
||||
rpcQueue chan inboundRPC
|
||||
rpcStop chan struct{}
|
||||
rpcCancel context.CancelFunc
|
||||
rpcClose sync.Once
|
||||
rpcWG sync.WaitGroup
|
||||
rpcTimeout time.Duration
|
||||
// inflightRPCBytes 跟踪已入队未完成的 inbound RPC body 总字节,配合 maxInflightRPCBytes
|
||||
// 给 RPC 队列设字节预算(不止限条数),防对抗客户端发大请求撑内存。
|
||||
inflightRPCBytes atomic.Int64
|
||||
// RPC worker 懒启动:首个 RPC 入队时才起 worker(ensureInboundRPCWorkers),
|
||||
// 避免握手后静默 / 纯推送目标连接白白钉住 rpcMaxInflight 个 goroutine。
|
||||
rpcRootCtx context.Context
|
||||
rpcMaxInflight int
|
||||
rpcWorkersOnce sync.Once
|
||||
|
||||
// sentContentMessages 只由 outbound actor 访问,用于生成 MTProto seq_no。
|
||||
sentContentMessages int32
|
||||
|
||||
identityMu sync.RWMutex
|
||||
businessAuthKeyID [8]byte
|
||||
businessAuthKeyResolved bool
|
||||
userID atomic.Int64
|
||||
userIDResolved atomic.Bool
|
||||
receivesUpdates atomic.Bool
|
||||
}
|
||||
|
||||
// AuthKeyID 返回连接的 auth_key_id。
|
||||
func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID }
|
||||
|
||||
// BusinessAuthKeyID 返回业务视角的 auth_key_id。
|
||||
//
|
||||
// temp auth_key 绑定后解析为 perm auth_key;第二个返回值表示本连接是否已完成解析,
|
||||
// 即便解析结果等于原始 auth_key_id 也会返回 true,以避免每个 RPC 重复查绑定表。
|
||||
func (c *Conn) BusinessAuthKeyID() ([8]byte, bool) {
|
||||
c.identityMu.RLock()
|
||||
defer c.identityMu.RUnlock()
|
||||
return c.businessAuthKeyID, c.businessAuthKeyResolved
|
||||
}
|
||||
|
||||
// SetBusinessAuthKeyID 缓存业务视角 auth_key_id。
|
||||
func (c *Conn) SetBusinessAuthKeyID(id [8]byte) {
|
||||
c.identityMu.Lock()
|
||||
changed := !c.businessAuthKeyResolved || c.businessAuthKeyID != id
|
||||
c.businessAuthKeyID = id
|
||||
c.businessAuthKeyResolved = true
|
||||
c.identityMu.Unlock()
|
||||
if changed {
|
||||
c.userID.Store(0)
|
||||
c.userIDResolved.Store(false)
|
||||
}
|
||||
}
|
||||
|
||||
// SessionID 返回连接的 session_id。
|
||||
func (c *Conn) SessionID() int64 { return c.sessionID }
|
||||
|
||||
// UserID 返回绑定的用户 id;未登录为 0。
|
||||
func (c *Conn) UserID() int64 { return c.userID.Load() }
|
||||
|
||||
// UserIDResolved 返回 user_id 授权状态是否已为当前连接解析过。
|
||||
//
|
||||
// resolved=true 且 userID=0 表示该 auth_key 当前未登录;这样登录前的多次 RPC
|
||||
// 不会反复查询授权表,后续登录成功会由 BindUser 覆盖为真实用户。
|
||||
func (c *Conn) UserIDResolved() (userID int64, resolved bool) {
|
||||
return c.userID.Load(), c.userIDResolved.Load()
|
||||
}
|
||||
|
||||
// ReceivesUpdates 报告该连接是否接收主动推送的 updates。
|
||||
func (c *Conn) ReceivesUpdates() bool { return c.receivesUpdates.Load() }
|
||||
|
||||
// SetReceivesUpdates 设置该连接是否接收主动推送的 updates。
|
||||
// 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。
|
||||
func (c *Conn) SetReceivesUpdates(v bool) { c.receivesUpdates.Store(v) }
|
||||
33
internal/mtprotoedge/destroy_auth_key.go
Normal file
33
internal/mtprotoedge/destroy_auth_key.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
)
|
||||
|
||||
const (
|
||||
destroyAuthKeyRequestTypeID = 0xd1435160
|
||||
destroyAuthKeyOkTypeID = 0xf660e1d4
|
||||
)
|
||||
|
||||
type destroyAuthKeyRequest struct{}
|
||||
|
||||
func (*destroyAuthKeyRequest) Encode(b *bin.Buffer) error {
|
||||
b.PutID(destroyAuthKeyRequestTypeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*destroyAuthKeyRequest) Decode(b *bin.Buffer) error {
|
||||
if err := b.ConsumeID(destroyAuthKeyRequestTypeID); err != nil {
|
||||
return fmt.Errorf("decode destroy_auth_key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type destroyAuthKeyOk struct{}
|
||||
|
||||
func (*destroyAuthKeyOk) Encode(b *bin.Buffer) error {
|
||||
b.PutID(destroyAuthKeyOkTypeID)
|
||||
return nil
|
||||
}
|
||||
6
internal/mtprotoedge/doc.go
Normal file
6
internal/mtprotoedge/doc.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Package mtprotoedge 是 MTProto 连接层:TCP/WS listener、密钥交换、auth key 查找与持久化、
|
||||
// 消息加解密、session/server-salt/msg-id/ack/container/gzip,以及 invokeWithLayer、initConnection、
|
||||
// invokeWithoutUpdates 等 wrapper 的 unwrap。
|
||||
//
|
||||
// 它只把 MTProto 世界转换成「已解密、已识别 session 的 RPC 请求」,不得包含业务逻辑。
|
||||
package mtprotoedge
|
||||
90
internal/mtprotoedge/e2e_test.go
Normal file
90
internal/mtprotoedge/e2e_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/session"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/app/auth"
|
||||
"telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/users"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestTelegramClientEndToEnd 是连接层的最强端到端验证:用 gotd/td 的完整
|
||||
// telegram.Client(而非底层 cipher)连本地 mtprotoedge,client 自动经
|
||||
// invokeWithLayer(initConnection(help.getConfig)) 完成初始化,并取得含本地 DC 的 Config。
|
||||
func TestTelegramClientEndToEnd(t *testing.T) {
|
||||
const dc = 2
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen rsa: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tcpAddr := ln.Addr().(*net.TCPAddr)
|
||||
|
||||
userStore := memory.NewUserStore()
|
||||
authzStore := memory.NewAuthorizationStore()
|
||||
authKeyStore := memory.NewAuthKeyStore()
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), "12345"),
|
||||
Users: users.NewService(userStore),
|
||||
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
|
||||
}
|
||||
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
|
||||
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
opts := telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
|
||||
Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
|
||||
DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
|
||||
Logger: zaptest.NewLogger(t).Named("client"),
|
||||
SessionStorage: &session.StorageMemory{},
|
||||
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
|
||||
}
|
||||
client := telegram.NewClient(1, "hash", opts)
|
||||
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
cfg, err := tg.NewClient(client).HelpGetConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.ThisDC != dc {
|
||||
t.Errorf("config.ThisDC = %d, want %d", cfg.ThisDC, dc)
|
||||
}
|
||||
if len(cfg.DCOptions) == 0 {
|
||||
t.Error("config.DCOptions is empty")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("telegram client run: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
702
internal/mtprotoedge/encrypted.go
Normal file
702
internal/mtprotoedge/encrypted.go
Normal file
|
|
@ -0,0 +1,702 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// connState 是单连接的 MTProto 运行态。
|
||||
type connState struct {
|
||||
sentCreated bool
|
||||
seen map[int64]clientMsgRecord // 已处理的 client msg_id,用于幂等和 msgs_state_req
|
||||
order []int64
|
||||
minSeen int64
|
||||
maxSeen int64
|
||||
}
|
||||
|
||||
type clientMsgRecord struct {
|
||||
state byte
|
||||
seqNo int32
|
||||
content bool
|
||||
}
|
||||
|
||||
func newConnState() *connState {
|
||||
return &connState{
|
||||
seen: make(map[int64]clientMsgRecord),
|
||||
minSeen: math.MaxInt64,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
maxTrackedClientMsgIDs = 400
|
||||
|
||||
msgStateUnknown byte = 1
|
||||
msgStateNotReceived byte = 2
|
||||
msgStateNotReceivedHigh byte = 3
|
||||
msgStateReceived byte = 4
|
||||
|
||||
badMsgIDTooLow = 16
|
||||
badMsgIDTooHigh = 17
|
||||
badMsgIDInvalidBits = 18
|
||||
badMsgSeqTooLow = 32
|
||||
badMsgSeqTooHigh = 33
|
||||
badMsgSeqNotEven = 34
|
||||
badMsgSeqNotOdd = 35
|
||||
badMsgContainer = 64
|
||||
)
|
||||
|
||||
// handleEncrypted 解密加密消息,按需注册连接,处理服务消息并分发明文 payload。
|
||||
// 返回(可能新建/更新的)当前连接对象,供 serveConn 维护生命周期。
|
||||
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, keyData store.AuthKeyData, b *bin.Buffer) (*Conn, error) {
|
||||
key := crypto.AuthKey{Value: crypto.Key(keyData.Value), ID: keyData.ID}
|
||||
|
||||
data, err := s.cipher.DecryptFromBuffer(key, b)
|
||||
if err != nil {
|
||||
return current, fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
if data.Salt != keyData.ServerSalt {
|
||||
c := current
|
||||
temp := false
|
||||
if c == nil || c.sessionID != data.SessionID {
|
||||
c = s.newConn(tc, key, data.SessionID, keyData.ServerSalt)
|
||||
temp = true
|
||||
}
|
||||
err := s.sendBadServerSalt(ctx, c, data.MessageID, data.SeqNo, keyData.ServerSalt)
|
||||
if temp {
|
||||
c.Close()
|
||||
}
|
||||
return current, err
|
||||
}
|
||||
|
||||
// 首个加密消息或 session 变化时(重新)注册连接到 SessionManager。
|
||||
if current == nil || current.sessionID != data.SessionID {
|
||||
if current != nil {
|
||||
s.conns.Unregister(current)
|
||||
current.Close()
|
||||
}
|
||||
current = s.newConn(tc, key, data.SessionID, keyData.ServerSalt)
|
||||
s.conns.Register(current)
|
||||
}
|
||||
|
||||
if err := s.sessions.Save(ctx, store.SessionData{
|
||||
ID: data.SessionID,
|
||||
AuthKeyID: key.ID,
|
||||
Salt: keyData.ServerSalt,
|
||||
LastSeen: s.clock.Now().Unix(),
|
||||
}); err != nil {
|
||||
return current, fmt.Errorf("save session: %w", err)
|
||||
}
|
||||
|
||||
body := data.Data()
|
||||
typeID, err := (&bin.Buffer{Buf: body}).PeekID()
|
||||
if err != nil {
|
||||
return current, fmt.Errorf("peek encrypted payload type id: %w", err)
|
||||
}
|
||||
if code := validateClientEnvelope(s.clock.Now(), data.MessageID, data.SeqNo, typeID); code != 0 {
|
||||
s.log.Debug("Sending bad_msg_notification",
|
||||
zap.Int64("msg_id", data.MessageID),
|
||||
zap.Int32("seq_no", data.SeqNo),
|
||||
zap.Uint32("type_id", typeID),
|
||||
zap.Int("code", code),
|
||||
)
|
||||
return current, s.sendBadMsg(ctx, current, data.MessageID, data.SeqNo, code)
|
||||
}
|
||||
|
||||
content := clientMessageNeedsAck(typeID)
|
||||
if record, ok := cs.seenRecord(data.MessageID); ok {
|
||||
s.log.Debug("Duplicate msg_id; re-ack only", zap.Int64("msg_id", data.MessageID))
|
||||
if resent, err := current.ResendByRequest(ctx, data.MessageID); err != nil {
|
||||
return current, err
|
||||
} else if resent {
|
||||
s.log.Debug("Resent cached rpc_result for duplicate msg_id", zap.Int64("msg_id", data.MessageID))
|
||||
}
|
||||
if !record.content {
|
||||
return current, nil
|
||||
}
|
||||
return current, s.sendAck(ctx, current, data.MessageID)
|
||||
}
|
||||
if code := cs.validateSeq(data.MessageID, data.SeqNo, content); code != 0 {
|
||||
s.log.Debug("Sending bad_msg_notification",
|
||||
zap.Int64("msg_id", data.MessageID),
|
||||
zap.Int32("seq_no", data.SeqNo),
|
||||
zap.Uint32("type_id", typeID),
|
||||
zap.Int("code", code),
|
||||
)
|
||||
return current, s.sendBadMsg(ctx, current, data.MessageID, data.SeqNo, code)
|
||||
}
|
||||
cs.track(data.MessageID, data.SeqNo, content, msgStateReceived)
|
||||
|
||||
if !cs.sentCreated {
|
||||
cs.sentCreated = true
|
||||
s.log.Debug("Sending new_session_created", zap.Int64("msg_id", data.MessageID), zap.Int32("seq_no", data.SeqNo))
|
||||
if err := s.sendNewSessionCreated(ctx, current, data.MessageID); err != nil {
|
||||
return current, err
|
||||
}
|
||||
}
|
||||
|
||||
var acks []int64
|
||||
if err := s.dispatch(ctx, cs, current, data.MessageID, data.SeqNo, &bin.Buffer{Buf: body}, &acks); err != nil {
|
||||
return current, err
|
||||
}
|
||||
if len(acks) > 0 {
|
||||
if err := s.sendAck(ctx, current, acks...); err != nil {
|
||||
return current, err
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// dispatch 处理一条明文消息:解包 container/gzip,处理服务消息,其余转 RPC 路由。
|
||||
// content-related 消息(ping、RPC)的 msg_id 会收集到 acks 以便统一确认。
|
||||
func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int64, seqNo int32, b *bin.Buffer, acks *[]int64) error {
|
||||
id, err := b.PeekID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("peek type id: %w", err)
|
||||
}
|
||||
ackContent := func() {
|
||||
if clientMessageNeedsAck(id) {
|
||||
*acks = append(*acks, msgID)
|
||||
}
|
||||
}
|
||||
|
||||
switch id {
|
||||
case proto.GZIPTypeID:
|
||||
var gz proto.GZIP
|
||||
if err := gz.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode gzip: %w", err)
|
||||
}
|
||||
return s.dispatch(ctx, cs, c, msgID, seqNo, &bin.Buffer{Buf: gz.Data}, acks)
|
||||
|
||||
case proto.MessageContainerTypeID:
|
||||
var container proto.MessageContainer
|
||||
if err := container.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode container: %w", err)
|
||||
}
|
||||
if code := validateClientContainer(msgID, seqNo, container); code != 0 {
|
||||
return s.sendBadMsg(ctx, c, msgID, seqNo, code)
|
||||
}
|
||||
for i := range container.Messages {
|
||||
m := container.Messages[i]
|
||||
typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("peek container message type id: %w", err)
|
||||
}
|
||||
content := clientMessageNeedsAck(typeID)
|
||||
if record, ok := cs.seenRecord(m.ID); ok {
|
||||
if record.content {
|
||||
*acks = append(*acks, m.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if code := cs.validateSeq(m.ID, int32(m.SeqNo), content); code != 0 {
|
||||
return s.sendBadMsg(ctx, c, m.ID, int32(m.SeqNo), code)
|
||||
}
|
||||
cs.track(m.ID, int32(m.SeqNo), content, msgStateReceived)
|
||||
if err := s.dispatch(ctx, cs, c, m.ID, int32(m.SeqNo), &bin.Buffer{Buf: m.Body}, acks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case mt.PingRequestTypeID:
|
||||
var ping mt.PingRequest
|
||||
if err := ping.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode ping: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
return s.sendPong(ctx, c, msgID, ping.PingID)
|
||||
|
||||
case mt.PingDelayDisconnectRequestTypeID:
|
||||
var ping mt.PingDelayDisconnectRequest
|
||||
if err := ping.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode ping_delay_disconnect: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
return s.sendPong(ctx, c, msgID, ping.PingID)
|
||||
|
||||
case mt.GetFutureSaltsRequestTypeID:
|
||||
var req mt.GetFutureSaltsRequest
|
||||
if err := req.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode get_future_salts: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
return s.sendFutureSalts(ctx, c, msgID, req.Num)
|
||||
|
||||
case mt.MsgsAckTypeID:
|
||||
var ack mt.MsgsAck
|
||||
if err := ack.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode msgs_ack: %w", err)
|
||||
}
|
||||
c.AckServerMessages(ack.MsgIDs)
|
||||
s.log.Debug("Received msgs_ack", zap.Int64s("msg_ids", ack.MsgIDs))
|
||||
return nil
|
||||
|
||||
case mt.MsgsStateReqTypeID:
|
||||
var req mt.MsgsStateReq
|
||||
if err := req.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode msgs_state_req: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
outgoing, err := c.OutgoingStateInfo(ctx, req.MsgIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs)))
|
||||
|
||||
case mt.MsgResendReqTypeID:
|
||||
var req mt.MsgResendReq
|
||||
if err := req.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode msg_resend_req: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
outgoing, err := c.ResendMessages(ctx, req.MsgIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs)))
|
||||
|
||||
case mt.MsgsStateInfoTypeID:
|
||||
var info mt.MsgsStateInfo
|
||||
if err := info.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode msgs_state_info: %w", err)
|
||||
}
|
||||
s.log.Debug("Received msgs_state_info", zap.Int64("req_msg_id", info.ReqMsgID), zap.Int("len", len(info.Info)))
|
||||
return nil
|
||||
|
||||
case mt.MsgsAllInfoTypeID:
|
||||
var info mt.MsgsAllInfo
|
||||
if err := info.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode msgs_all_info: %w", err)
|
||||
}
|
||||
s.log.Debug("Received msgs_all_info", zap.Int("msg_ids", len(info.MsgIDs)), zap.Int("len", len(info.Info)))
|
||||
return nil
|
||||
|
||||
case mt.DestroySessionRequestTypeID:
|
||||
var req mt.DestroySessionRequest
|
||||
if err := req.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode destroy_session: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
return s.sendDestroySession(ctx, c, req.SessionID)
|
||||
|
||||
case mt.HTTPWaitRequestTypeID:
|
||||
var req mt.HTTPWaitRequest
|
||||
if err := req.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode http_wait: %w", err)
|
||||
}
|
||||
s.log.Debug("Received http_wait",
|
||||
zap.Int("max_delay", req.MaxDelay),
|
||||
zap.Int("wait_after", req.WaitAfter),
|
||||
zap.Int("max_wait", req.MaxWait),
|
||||
)
|
||||
return nil
|
||||
|
||||
case mt.RPCDropAnswerRequestTypeID:
|
||||
var req mt.RPCDropAnswerRequest
|
||||
if err := req.Decode(b); err != nil {
|
||||
return fmt.Errorf("decode rpc_drop_answer: %w", err)
|
||||
}
|
||||
ackContent()
|
||||
s.log.Debug("Received rpc_drop_answer", zap.Int64("req_msg_id", req.ReqMsgID))
|
||||
return s.sendResult(ctx, c, msgID, &mt.RPCAnswerUnknown{})
|
||||
|
||||
case destroyAuthKeyRequestTypeID:
|
||||
var req destroyAuthKeyRequest
|
||||
if err := req.Decode(b); err != nil {
|
||||
return err
|
||||
}
|
||||
ackContent()
|
||||
s.log.Debug("Received destroy_auth_key", zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])))
|
||||
return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyOk{})
|
||||
|
||||
default:
|
||||
ackContent()
|
||||
body := b.Copy()
|
||||
return s.enqueueRPC(ctx, c, msgID, body)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeStateInfo(primary, fallback []byte) []byte {
|
||||
if len(primary) == 0 {
|
||||
return fallback
|
||||
}
|
||||
info := make([]byte, len(fallback))
|
||||
copy(info, fallback)
|
||||
for i, state := range primary {
|
||||
if i >= len(info) {
|
||||
break
|
||||
}
|
||||
if state != 0 {
|
||||
info[i] = state
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, body []byte) error {
|
||||
id, _ := (&bin.Buffer{Buf: body}).PeekID()
|
||||
method := s.typeName(id)
|
||||
err := c.enqueueInboundRPC(ctx, inboundRPC{
|
||||
method: method,
|
||||
size: len(body),
|
||||
run: func(taskCtx context.Context) error {
|
||||
// body 已是 enqueueRPC 入参的独立副本(dispatch 里 b.Copy()),且每个任务只 run 一次,
|
||||
// 无需再 append 拷贝;直接复用,省掉一份 inbound 在途内存。
|
||||
if err := s.handleRPC(taskCtx, c, msgID, &bin.Buffer{Buf: body}); err != nil {
|
||||
s.log.Info("RPC async handler failed",
|
||||
zap.Int64("msg_id", msgID),
|
||||
zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])),
|
||||
zap.Int64("session_id", c.sessionID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if errors.Is(err, ErrInboundRPCQueueFull) {
|
||||
s.log.Debug("Inbound RPC queue full",
|
||||
zap.String("method", method),
|
||||
zap.Int64("msg_id", msgID),
|
||||
zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])),
|
||||
zap.Int64("session_id", c.sessionID),
|
||||
)
|
||||
return s.sendResult(ctx, c, msgID, &mt.RPCError{
|
||||
ErrorCode: 420,
|
||||
ErrorMessage: "FLOOD_WAIT_1",
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// handleRPC 把明文 RPC 请求交给 RPC 路由,并将结果或错误包成 rpc_result 回发。
|
||||
func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, b *bin.Buffer) error {
|
||||
id, _ := b.PeekID()
|
||||
method := s.typeName(id)
|
||||
if s.rpc == nil {
|
||||
s.log.Warn("No RPC handler configured; dropping request", zap.String("method", method))
|
||||
return nil
|
||||
}
|
||||
|
||||
start := s.clock.Now()
|
||||
result, err := s.rpc.Dispatch(ctx, c.authKeyID, c.sessionID, b)
|
||||
dur := s.clock.Now().Sub(start)
|
||||
s.metrics.RPCHandled(method, dur, err)
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("method", method),
|
||||
zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])),
|
||||
zap.Int64("session_id", c.sessionID),
|
||||
zap.Int64("msg_id", msgID),
|
||||
zap.Duration("dur", dur),
|
||||
}
|
||||
if businessAuthKeyID, ok := c.BusinessAuthKeyID(); ok {
|
||||
fields = append(fields, zap.String("business_auth_key_id", hex.EncodeToString(businessAuthKeyID[:])))
|
||||
}
|
||||
if userID := c.UserID(); userID != 0 {
|
||||
fields = append(fields, zap.Int64("user_id", userID))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var rpcErr *tgerr.Error
|
||||
if errors.As(err, &rpcErr) {
|
||||
s.log.Info("RPC error", append(fields, zap.Int("code", rpcErr.Code), zap.String("error", rpcErr.Message))...)
|
||||
return s.sendResult(ctx, c, msgID, &mt.RPCError{
|
||||
ErrorCode: rpcErr.Code,
|
||||
ErrorMessage: rpcErr.Message,
|
||||
})
|
||||
}
|
||||
s.log.Info("RPC internal error", append(fields, zap.Error(err))...)
|
||||
return s.sendResult(ctx, c, msgID, &mt.RPCError{
|
||||
ErrorCode: 500,
|
||||
ErrorMessage: "INTERNAL",
|
||||
})
|
||||
}
|
||||
|
||||
s.log.Info("RPC handled", fields...)
|
||||
return s.sendResult(ctx, c, msgID, result)
|
||||
}
|
||||
|
||||
// sendResult 把 RPC 结果包成 rpc_result 并加密回发。
|
||||
func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) error {
|
||||
var buf bin.Buffer
|
||||
if err := result.Encode(&buf); err != nil {
|
||||
return fmt.Errorf("encode rpc result: %w", err)
|
||||
}
|
||||
return c.Send(ctx, proto.MessageServerResponse, &proto.Result{
|
||||
RequestMessageID: reqMsgID,
|
||||
Result: buf.Raw(),
|
||||
})
|
||||
}
|
||||
|
||||
// sendPong 回复 mt.PingRequest / mt.PingDelayDisconnectRequest。
|
||||
func (s *Server) sendPong(ctx context.Context, c *Conn, reqMsgID, pingID int64) error {
|
||||
return c.SendAsync(ctx, proto.MessageServerResponse, &mt.Pong{MsgID: reqMsgID, PingID: pingID})
|
||||
}
|
||||
|
||||
// sendFutureSalts 回复 MTProto get_future_salts。
|
||||
//
|
||||
// 第一阶段只维护当前 auth key 的权威 server_salt,因此返回当前 salt 的有效窗口。
|
||||
// 后续如引入 salt rotation,可在这里扩展为多条未来 salt。
|
||||
func (s *Server) sendFutureSalts(ctx context.Context, c *Conn, reqMsgID int64, num int) error {
|
||||
if num < 0 {
|
||||
num = 0
|
||||
}
|
||||
if num > 1 {
|
||||
num = 1
|
||||
}
|
||||
now := int(s.clock.Now().Unix())
|
||||
salts := make([]mt.FutureSalt, 0, num)
|
||||
if num == 1 {
|
||||
salts = append(salts, mt.FutureSalt{
|
||||
ValidSince: now - 300,
|
||||
ValidUntil: now + 24*60*60,
|
||||
Salt: c.salt,
|
||||
})
|
||||
}
|
||||
return c.SendAsync(ctx, proto.MessageServerResponse, &mt.FutureSalts{
|
||||
ReqMsgID: reqMsgID,
|
||||
Now: now,
|
||||
Salts: salts,
|
||||
})
|
||||
}
|
||||
|
||||
// sendNewSessionCreated 在连接首个加密消息后通知客户端新 session 已建立。
|
||||
func (s *Server) sendNewSessionCreated(ctx context.Context, c *Conn, firstMsgID int64) error {
|
||||
return c.SendAsync(ctx, proto.MessageFromServer, &mt.NewSessionCreated{
|
||||
FirstMsgID: firstMsgID,
|
||||
UniqueID: s.sessionUID,
|
||||
ServerSalt: c.salt,
|
||||
})
|
||||
}
|
||||
|
||||
// sendAck 确认收到客户端 content-related 消息。
|
||||
func (s *Server) sendAck(ctx context.Context, c *Conn, ids ...int64) error {
|
||||
return c.SendAsync(ctx, proto.MessageFromServer, &mt.MsgsAck{MsgIDs: ids})
|
||||
}
|
||||
|
||||
// sendMsgsStateInfo 回复 msgs_state_req/msg_resend_req。
|
||||
func (s *Server) sendMsgsStateInfo(ctx context.Context, c *Conn, reqMsgID int64, info []byte) error {
|
||||
return c.SendAsync(ctx, proto.MessageServerResponse, &mt.MsgsStateInfo{ReqMsgID: reqMsgID, Info: info})
|
||||
}
|
||||
|
||||
func (s *Server) sendDestroySession(ctx context.Context, c *Conn, sessionID int64) error {
|
||||
removed := false
|
||||
if sessionID != c.sessionID {
|
||||
removed = s.conns.DestroySessionForAuthKey(c.authKeyID, sessionID)
|
||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
||||
s.log.Debug("Delete session record failed",
|
||||
zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])),
|
||||
zap.Int64("session_id", sessionID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionOk{SessionID: sessionID})
|
||||
}
|
||||
return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionNone{SessionID: sessionID})
|
||||
}
|
||||
|
||||
// sendBadMsg 通知客户端消息存在协议层错误(msg_id/seqno 非法)。
|
||||
func (s *Server) sendBadMsg(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, code int) error {
|
||||
return c.SendAsync(ctx, proto.MessageFromServer, &mt.BadMsgNotification{
|
||||
BadMsgID: badMsgID,
|
||||
BadMsgSeqno: int(badSeqno),
|
||||
ErrorCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
// sendBadServerSalt 通知客户端修正 server_salt(error_code 48)。
|
||||
func (s *Server) sendBadServerSalt(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, newSalt int64) error {
|
||||
return c.SendPriority(ctx, proto.MessageFromServer, &mt.BadServerSalt{
|
||||
BadMsgID: badMsgID,
|
||||
BadMsgSeqno: int(badSeqno),
|
||||
ErrorCode: 48,
|
||||
NewServerSalt: newSalt,
|
||||
})
|
||||
}
|
||||
|
||||
// typeName 返回 TL TypeID 的可读名称,未知时回退到 hex。
|
||||
func (s *Server) typeName(id uint32) string {
|
||||
if name := s.types.Get(id); name != "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%#x", id)
|
||||
}
|
||||
|
||||
func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint32) int {
|
||||
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient {
|
||||
return badMsgIDInvalidBits
|
||||
}
|
||||
msgTime := proto.MessageID(msgID).Time()
|
||||
if msgTime.Before(now.Add(-300 * time.Second)) {
|
||||
return badMsgIDTooLow
|
||||
}
|
||||
if msgTime.After(now.Add(30 * time.Second)) {
|
||||
return badMsgIDTooHigh
|
||||
}
|
||||
if clientMessageNeedsAck(typeID) {
|
||||
if seqNo%2 == 0 {
|
||||
return badMsgSeqNotOdd
|
||||
}
|
||||
} else if seqNo%2 != 0 {
|
||||
return badMsgSeqNotEven
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func validateClientContainer(containerMsgID int64, containerSeqNo int32, container proto.MessageContainer) int {
|
||||
for _, m := range container.Messages {
|
||||
if m.ID >= containerMsgID || int32(m.SeqNo) > containerSeqNo {
|
||||
return badMsgContainer
|
||||
}
|
||||
typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID()
|
||||
if err != nil {
|
||||
return badMsgContainer
|
||||
}
|
||||
if typeID == proto.MessageContainerTypeID {
|
||||
return badMsgContainer
|
||||
}
|
||||
if code := validateClientContainerEnvelope(m.ID, int32(m.SeqNo), typeID); code != 0 {
|
||||
return badMsgContainer
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int {
|
||||
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient {
|
||||
return badMsgIDInvalidBits
|
||||
}
|
||||
if clientMessageNeedsAck(typeID) {
|
||||
if seqNo%2 == 0 {
|
||||
return badMsgSeqNotOdd
|
||||
}
|
||||
} else if seqNo%2 != 0 {
|
||||
return badMsgSeqNotEven
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func clientMessageNeedsAck(typeID uint32) bool {
|
||||
switch typeID {
|
||||
case proto.MessageContainerTypeID,
|
||||
mt.MsgsAckTypeID,
|
||||
mt.HTTPWaitRequestTypeID,
|
||||
mt.BadMsgNotificationTypeID,
|
||||
mt.BadServerSaltTypeID,
|
||||
mt.MsgsAllInfoTypeID,
|
||||
mt.MsgsStateInfoTypeID,
|
||||
mt.MsgDetailedInfoTypeID,
|
||||
mt.MsgNewDetailedInfoTypeID:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *connState) seenRecord(msgID int64) (clientMsgRecord, bool) {
|
||||
record, ok := cs.seen[msgID]
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (cs *connState) validateSeq(msgID int64, seqNo int32, content bool) int {
|
||||
if !content {
|
||||
return 0
|
||||
}
|
||||
for seenMsgID, record := range cs.seen {
|
||||
if !record.content {
|
||||
continue
|
||||
}
|
||||
if seenMsgID < msgID && record.seqNo >= seqNo {
|
||||
return badMsgSeqTooLow
|
||||
}
|
||||
if seenMsgID > msgID && record.seqNo <= seqNo {
|
||||
return badMsgSeqTooHigh
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (cs *connState) track(msgID int64, seqNo int32, content bool, state byte) {
|
||||
cs.seen[msgID] = clientMsgRecord{
|
||||
state: state,
|
||||
seqNo: seqNo,
|
||||
content: content,
|
||||
}
|
||||
cs.order = append(cs.order, msgID)
|
||||
if msgID < cs.minSeen {
|
||||
cs.minSeen = msgID
|
||||
}
|
||||
if msgID > cs.maxSeen {
|
||||
cs.maxSeen = msgID
|
||||
}
|
||||
if len(cs.order) > maxTrackedClientMsgIDs {
|
||||
oldest := cs.order[0]
|
||||
cs.order = cs.order[1:]
|
||||
delete(cs.seen, oldest)
|
||||
if oldest == cs.minSeen || oldest == cs.maxSeen {
|
||||
cs.recomputeRange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *connState) stateInfo(msgIDs []int64) []byte {
|
||||
info := make([]byte, len(msgIDs))
|
||||
if len(cs.seen) == 0 {
|
||||
for i := range info {
|
||||
info[i] = msgStateUnknown
|
||||
}
|
||||
return info
|
||||
}
|
||||
for i, id := range msgIDs {
|
||||
if id < cs.minSeen {
|
||||
info[i] = msgStateUnknown
|
||||
continue
|
||||
}
|
||||
if id > cs.maxSeen {
|
||||
info[i] = msgStateNotReceivedHigh
|
||||
continue
|
||||
}
|
||||
record, ok := cs.seen[id]
|
||||
if !ok {
|
||||
info[i] = msgStateNotReceived
|
||||
continue
|
||||
}
|
||||
info[i] = record.state
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func (cs *connState) recomputeRange() {
|
||||
cs.minSeen = math.MaxInt64
|
||||
cs.maxSeen = 0
|
||||
for id := range cs.seen {
|
||||
if id < cs.minSeen {
|
||||
cs.minSeen = id
|
||||
}
|
||||
if id > cs.maxSeen {
|
||||
cs.maxSeen = id
|
||||
}
|
||||
}
|
||||
if len(cs.seen) == 0 {
|
||||
cs.minSeen = math.MaxInt64
|
||||
}
|
||||
}
|
||||
435
internal/mtprotoedge/encrypted_test.go
Normal file
435
internal/mtprotoedge/encrypted_test.go
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
)
|
||||
|
||||
// TestEncryptedPingPong 验证 M2/M4:握手后 client 加密 ping,
|
||||
// server 回 new_session_created + pong + msgs_ack。
|
||||
func TestEncryptedPingPong(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
const pingID int64 = 0x1234beef
|
||||
pingMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncrypted(t, conn, cipher, auth, pingMsgID, &mt.PingRequest{PingID: pingID})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
|
||||
mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created")
|
||||
pongBuf := mustHave(t, replies, mt.PongTypeID, "pong")
|
||||
|
||||
var pong mt.Pong
|
||||
if err := pong.Decode(pongBuf); err != nil {
|
||||
t.Fatalf("decode pong: %v", err)
|
||||
}
|
||||
if pong.PingID != pingID {
|
||||
t.Fatalf("pong.PingID = %#x, want %#x", pong.PingID, pingID)
|
||||
}
|
||||
if pong.MsgID != pingMsgID {
|
||||
t.Fatalf("pong.MsgID = %d, want %d (req msg id)", pong.MsgID, pingMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDuplicateMsgIDIdempotent 验证 M4:相同 msg_id 的重复 content 请求被幂等处理,
|
||||
// server 重发已缓存的 rpc_result,并重新 ack,不重复执行业务。
|
||||
func TestDuplicateMsgIDIdempotent(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
msgID := clientMsgID.New(proto.MessageFromClient)
|
||||
|
||||
sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4})
|
||||
first := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
mustHave(t, first, proto.ResultTypeID, "first rpc_result")
|
||||
|
||||
// 相同 msg_id —— 幂等:重发已有 rpc_result,并重新 ack。
|
||||
sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4})
|
||||
second := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
mustHave(t, second, proto.ResultTypeID, "resent rpc_result")
|
||||
mustHave(t, second, mt.MsgsAckTypeID, "second ack")
|
||||
}
|
||||
|
||||
// TestGetFutureSalts 验证 MTProto service message get_future_salts 由连接层直接响应,
|
||||
// 不再落到业务 RPC fallback。
|
||||
func TestGetFutureSalts(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.GetFutureSaltsRequest{Num: 32})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.FutureSaltsTypeID)
|
||||
buf := mustHave(t, replies, mt.FutureSaltsTypeID, "future_salts")
|
||||
|
||||
var salts mt.FutureSalts
|
||||
if err := salts.Decode(buf); err != nil {
|
||||
t.Fatalf("decode future_salts: %v", err)
|
||||
}
|
||||
if salts.ReqMsgID != reqMsgID {
|
||||
t.Fatalf("future_salts.req_msg_id = %d, want %d", salts.ReqMsgID, reqMsgID)
|
||||
}
|
||||
if len(salts.Salts) != 1 {
|
||||
t.Fatalf("future_salts len = %d, want 1", len(salts.Salts))
|
||||
}
|
||||
if got := salts.Salts[0].Salt; got != auth.ServerSalt {
|
||||
t.Fatalf("future salt = %#x, want server salt %#x", got, auth.ServerSalt)
|
||||
}
|
||||
if salts.Salts[0].ValidSince > salts.Now || salts.Salts[0].ValidUntil <= salts.Now {
|
||||
t.Fatalf("future salt validity = [%d,%d], now %d", salts.Salts[0].ValidSince, salts.Salts[0].ValidUntil, salts.Now)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMsgsStateReq 验证 MTProto service message msgs_state_req 由连接层直接响应,
|
||||
// 不再落到业务 RPC fallback。
|
||||
func TestMsgsStateReq(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
asked := []int64{reqMsgID, reqMsgID - 4, reqMsgID + 4}
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.MsgsStateReq{MsgIDs: asked})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsStateInfoTypeID)
|
||||
buf := mustHave(t, replies, mt.MsgsStateInfoTypeID, "msgs_state_info")
|
||||
|
||||
var info mt.MsgsStateInfo
|
||||
if err := info.Decode(buf); err != nil {
|
||||
t.Fatalf("decode msgs_state_info: %v", err)
|
||||
}
|
||||
if info.ReqMsgID != reqMsgID {
|
||||
t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID)
|
||||
}
|
||||
if len(info.Info) != len(asked) {
|
||||
t.Fatalf("msgs_state_info len = %d, want %d", len(info.Info), len(asked))
|
||||
}
|
||||
want := []byte{4, 1, 3}
|
||||
for i, b := range info.Info {
|
||||
if b != want[i] {
|
||||
t.Fatalf("msgs_state_info[%d] = %d, want %d", i, b, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMsgResendReq 验证 MTProto msg_resend_req 由连接层按状态查询兜底响应,
|
||||
// 不会落入业务 RPC fallback。
|
||||
func TestMsgResendReq(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
asked := []int64{reqMsgID, reqMsgID - 4, reqMsgID + 4}
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.MsgResendReq{MsgIDs: asked})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsStateInfoTypeID)
|
||||
buf := mustHave(t, replies, mt.MsgsStateInfoTypeID, "msgs_state_info")
|
||||
|
||||
var info mt.MsgsStateInfo
|
||||
if err := info.Decode(buf); err != nil {
|
||||
t.Fatalf("decode msgs_state_info: %v", err)
|
||||
}
|
||||
if info.ReqMsgID != reqMsgID {
|
||||
t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID)
|
||||
}
|
||||
if len(info.Info) != len(asked) {
|
||||
t.Fatalf("msgs_state_info len = %d, want %d", len(info.Info), len(asked))
|
||||
}
|
||||
want := []byte{4, 1, 3}
|
||||
for i, b := range info.Info {
|
||||
if b != want[i] {
|
||||
t.Fatalf("msgs_state_info[%d] = %d, want %d", i, b, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestroySession 验证 destroy_session 返回 raw DestroySessionRes,
|
||||
// 避免客户端清理旧 session 时掉到 RPC fallback。
|
||||
func TestDestroySession(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
targetSessionID := auth.SessionID + 4
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.DestroySessionRequest{SessionID: targetSessionID})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.DestroySessionNoneTypeID)
|
||||
buf := mustHave(t, replies, mt.DestroySessionNoneTypeID, "destroy_session_none")
|
||||
|
||||
var res mt.DestroySessionNone
|
||||
if err := res.Decode(buf); err != nil {
|
||||
t.Fatalf("decode destroy_session_none: %v", err)
|
||||
}
|
||||
if res.SessionID != targetSessionID {
|
||||
t.Fatalf("destroy_session_none.session_id = %d, want %d", res.SessionID, targetSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPCDropAnswer 验证 rpc_drop_answer 以 rpc_result 包装 RpcDropAnswer 返回,
|
||||
// 与 gotd/td 和 TDesktop 的请求/响应模型对齐。
|
||||
func TestRPCDropAnswer(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
droppedReqID := reqMsgID - 4
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.RPCDropAnswerRequest{ReqMsgID: droppedReqID})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
|
||||
buf := mustHave(t, replies, proto.ResultTypeID, "rpc_result")
|
||||
|
||||
var result proto.Result
|
||||
if err := result.Decode(buf); err != nil {
|
||||
t.Fatalf("decode rpc_result: %v", err)
|
||||
}
|
||||
if result.RequestMessageID != reqMsgID {
|
||||
t.Fatalf("rpc_result.req_msg_id = %d, want %d", result.RequestMessageID, reqMsgID)
|
||||
}
|
||||
answer, err := mt.DecodeRPCDropAnswer(&bin.Buffer{Buf: result.Result})
|
||||
if err != nil {
|
||||
t.Fatalf("decode RpcDropAnswer: %v", err)
|
||||
}
|
||||
if _, ok := answer.(*mt.RPCAnswerUnknown); !ok {
|
||||
t.Fatalf("RpcDropAnswer = %T, want *mt.RPCAnswerUnknown", answer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPWaitInContainerDoesNotNeedAck 验证 http_wait 在 container 中被协议层吞掉,
|
||||
// 但同 container 内的 ping 仍按 content-related service request 回 ack。
|
||||
func TestHTTPWaitInContainerDoesNotNeedAck(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
waitMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
pingMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
containerMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
waitBody := mustEncodeTL(t, &mt.HTTPWaitRequest{MaxDelay: 0, WaitAfter: 0, MaxWait: 25_000})
|
||||
pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 7})
|
||||
sendEncrypted(t, conn, cipher, auth, containerMsgID, &proto.MessageContainer{
|
||||
Messages: []proto.Message{
|
||||
{ID: waitMsgID, SeqNo: 0, Bytes: len(waitBody), Body: waitBody},
|
||||
{ID: pingMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody},
|
||||
},
|
||||
})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
mustHave(t, replies, mt.PongTypeID, "pong")
|
||||
ackBuf := mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack")
|
||||
var ack mt.MsgsAck
|
||||
if err := ack.Decode(ackBuf); err != nil {
|
||||
t.Fatalf("decode msgs_ack: %v", err)
|
||||
}
|
||||
if len(ack.MsgIDs) != 1 || ack.MsgIDs[0] != pingMsgID {
|
||||
t.Fatalf("msgs_ack = %+v, want only ping msg_id %d", ack.MsgIDs, pingMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOldMessageInFreshContainerAccepted verifies TDesktop's bad_msg recovery
|
||||
// path: an old request can be resent inside a fresh container msg_id.
|
||||
func TestOldMessageInFreshContainerAccepted(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
oldMsgIDGen := proto.NewMessageIDGen(func() time.Time {
|
||||
return time.Now().Add(-10 * time.Minute)
|
||||
})
|
||||
freshMsgIDGen := proto.NewMessageIDGen(time.Now)
|
||||
oldPingMsgID := oldMsgIDGen.New(proto.MessageFromClient)
|
||||
containerMsgID := freshMsgIDGen.New(proto.MessageFromClient)
|
||||
pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 42})
|
||||
|
||||
sendEncrypted(t, conn, cipher, auth, containerMsgID, &proto.MessageContainer{
|
||||
Messages: []proto.Message{
|
||||
{ID: oldPingMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody},
|
||||
},
|
||||
})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
|
||||
buf := mustHave(t, replies, mt.PongTypeID, "pong")
|
||||
var pong mt.Pong
|
||||
if err := pong.Decode(buf); err != nil {
|
||||
t.Fatalf("decode pong: %v", err)
|
||||
}
|
||||
if pong.MsgID != oldPingMsgID || pong.PingID != 42 {
|
||||
t.Fatalf("pong = %+v, want msg_id=%d ping_id=42", pong, oldPingMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingDelayDisconnectOddSeqAccepted(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 1, &mt.PingDelayDisconnectRequest{
|
||||
PingID: 9,
|
||||
DisconnectDelay: 60,
|
||||
})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
|
||||
buf := mustHave(t, replies, mt.PongTypeID, "pong")
|
||||
var pong mt.Pong
|
||||
if err := pong.Decode(buf); err != nil {
|
||||
t.Fatalf("decode pong: %v", err)
|
||||
}
|
||||
if pong.MsgID != reqMsgID || pong.PingID != 9 {
|
||||
t.Fatalf("pong = %+v, want msg_id=%d ping_id=9", pong, reqMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestroyAuthKey 验证 MTProto service message destroy_auth_key 由连接层直接响应,
|
||||
// 避免 TDesktop 清理旧 key 时落到业务 RPC fallback。
|
||||
func TestDestroyAuthKey(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &destroyAuthKeyRequest{})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, destroyAuthKeyOkTypeID)
|
||||
mustHave(t, replies, destroyAuthKeyOkTypeID, "destroy_auth_key_ok")
|
||||
}
|
||||
|
||||
// TestBadServerSalt 验证客户端带错 server_salt 时 server 返回 bad_server_salt,
|
||||
// 并携带当前 auth key 的权威 salt。
|
||||
func TestBadServerSalt(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
wrongSalt := auth.ServerSalt + 1
|
||||
sendEncryptedWithSalt(t, conn, cipher, auth, wrongSalt, reqMsgID, &mt.PingRequest{PingID: 1})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.BadServerSaltTypeID)
|
||||
buf := mustHave(t, replies, mt.BadServerSaltTypeID, "bad_server_salt")
|
||||
|
||||
var bad mt.BadServerSalt
|
||||
if err := bad.Decode(buf); err != nil {
|
||||
t.Fatalf("decode bad_server_salt: %v", err)
|
||||
}
|
||||
if bad.BadMsgID != reqMsgID {
|
||||
t.Fatalf("bad_server_salt.bad_msg_id = %d, want %d", bad.BadMsgID, reqMsgID)
|
||||
}
|
||||
if bad.ErrorCode != 48 {
|
||||
t.Fatalf("bad_server_salt.error_code = %d, want 48", bad.ErrorCode)
|
||||
}
|
||||
if bad.NewServerSalt != auth.ServerSalt {
|
||||
t.Fatalf("bad_server_salt.new_server_salt = %#x, want %#x", bad.NewServerSalt, auth.ServerSalt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMsgSeqOddExpected(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 0, &tg.HelpGetConfigRequest{})
|
||||
|
||||
bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey)
|
||||
if bad.BadMsgID != reqMsgID || bad.BadMsgSeqno != 0 || bad.ErrorCode != badMsgSeqNotOdd {
|
||||
t.Fatalf("bad_msg = %+v, want msg_id=%d seq=0 code=%d", bad, reqMsgID, badMsgSeqNotOdd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMsgSeqEvenExpected(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 1, &mt.MsgsAck{MsgIDs: []int64{reqMsgID}})
|
||||
|
||||
bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey)
|
||||
if bad.BadMsgID != reqMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqNotEven {
|
||||
t.Fatalf("bad_msg = %+v, want msg_id=%d seq=1 code=%d", bad, reqMsgID, badMsgSeqNotEven)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMsgSeqTooLow(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
firstMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, firstMsgID, 3, &tg.HelpGetConfigRequest{})
|
||||
collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
|
||||
secondMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, secondMsgID, 1, &tg.HelpGetConfigRequest{})
|
||||
|
||||
bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey)
|
||||
if bad.BadMsgID != secondMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqTooLow {
|
||||
t.Fatalf("bad_msg = %+v, want msg_id=%d seq=1 code=%d", bad, secondMsgID, badMsgSeqTooLow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMsgSeqTooHigh(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
lowMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
highMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, highMsgID, 1, &tg.HelpGetConfigRequest{})
|
||||
collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, lowMsgID, 3, &tg.HelpGetConfigRequest{})
|
||||
|
||||
bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey)
|
||||
if bad.BadMsgID != lowMsgID || bad.BadMsgSeqno != 3 || bad.ErrorCode != badMsgSeqTooHigh {
|
||||
t.Fatalf("bad_msg = %+v, want msg_id=%d seq=3 code=%d", bad, lowMsgID, badMsgSeqTooHigh)
|
||||
}
|
||||
}
|
||||
|
||||
func readBadMsgNotification(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) mt.BadMsgNotification {
|
||||
t.Helper()
|
||||
replies := collectReplies(t, conn, cipher, key, mt.BadMsgNotificationTypeID)
|
||||
buf := mustHave(t, replies, mt.BadMsgNotificationTypeID, "bad_msg_notification")
|
||||
var bad mt.BadMsgNotification
|
||||
if err := bad.Decode(buf); err != nil {
|
||||
t.Fatalf("decode bad_msg_notification: %v", err)
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
func mustEncodeTL(t *testing.T, msg bin.Encoder) []byte {
|
||||
t.Helper()
|
||||
var b bin.Buffer
|
||||
if err := msg.Encode(&b); err != nil {
|
||||
t.Fatalf("encode TL: %v", err)
|
||||
}
|
||||
return b.Copy()
|
||||
}
|
||||
152
internal/mtprotoedge/exchange.go
Normal file
152
internal/mtprotoedge/exchange.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// emptyAuthKeyID 是未加密消息(密钥交换)的 auth_key_id(全零)。
|
||||
var emptyAuthKeyID [8]byte
|
||||
|
||||
// peekAuthKeyID 读取消息前 8 字节的 auth_key_id,不消费 buffer。
|
||||
func peekAuthKeyID(b *bin.Buffer) (id [8]byte, err error) {
|
||||
err = b.PeekN(id[:], len(id))
|
||||
return id, err
|
||||
}
|
||||
|
||||
// handleExchange 在收到 auth_key_id==0 的首帧后执行服务端 MTProto 密钥交换。
|
||||
//
|
||||
// first 是已读取的首帧(req_pq*),通过 bufferedConn 交还给 exchange 流程,
|
||||
// 使其能从头读取握手消息。成功后将 auth key + server salt 落入 AuthKeyStore。
|
||||
func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first *bin.Buffer) (*bin.Buffer, error) {
|
||||
if s.key.Zero() {
|
||||
s.log.Error("Key exchange requested but server RSA key is not configured")
|
||||
return nil, s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound)
|
||||
}
|
||||
|
||||
buffered := newBufferedConn(conn)
|
||||
buffered.push(first)
|
||||
|
||||
start := s.clock.Now()
|
||||
res, err := exchange.NewExchanger(buffered, s.dc).
|
||||
WithClock(s.clock).
|
||||
WithRand(s.rand).
|
||||
WithLogger(s.log.Named("exchange")).
|
||||
Server(s.key).
|
||||
Run(ctx)
|
||||
if err != nil {
|
||||
if isEncryptedFrameDuringExchange(err) {
|
||||
replay := buffered.lastFrame()
|
||||
if replay != nil {
|
||||
s.log.Debug("Key exchange interrupted by encrypted frame; replaying as existing session")
|
||||
return replay, nil
|
||||
}
|
||||
}
|
||||
var exErr *exchange.ServerExchangeError
|
||||
if errors.As(err, &exErr) {
|
||||
s.log.Info("Key exchange rejected", zap.Int32("code", exErr.Code), zap.Error(err))
|
||||
return nil, s.sendProtoError(ctx, conn, exErr.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("key exchange: %w", err)
|
||||
}
|
||||
|
||||
s.metrics.HandshakeDone(s.clock.Now().Sub(start))
|
||||
s.log.Info("Key exchange completed",
|
||||
zap.Object("auth_key", res.Key),
|
||||
zap.Int64("server_salt", res.ServerSalt),
|
||||
zap.Duration("dur", s.clock.Now().Sub(start)),
|
||||
)
|
||||
|
||||
return nil, s.authKeys.Save(ctx, authKeyData(res.Key, res.ServerSalt, s.clock.Now().Unix()))
|
||||
}
|
||||
|
||||
func isEncryptedFrameDuringExchange(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "unexpected auth_key_id") && strings.Contains(msg, "plaintext message")
|
||||
}
|
||||
|
||||
// authKeyData 把握手结果转换为 store 记录。
|
||||
func authKeyData(key crypto.AuthKey, salt, createdAt int64) store.AuthKeyData {
|
||||
return store.AuthKeyData{
|
||||
ID: key.ID,
|
||||
Value: [256]byte(key.Value),
|
||||
ServerSalt: salt,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
// sendProtoError 向客户端发送 transport 级协议错误(-code)。
|
||||
func (s *Server) sendProtoError(ctx context.Context, conn transport.Conn, code int32) error {
|
||||
var buf bin.Buffer
|
||||
buf.PutInt32(-code)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, s.writeTimeout)
|
||||
defer cancel()
|
||||
if err := conn.Send(ctx, &buf); err != nil {
|
||||
return fmt.Errorf("send proto error %d: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bufferedConn 包装 transport.Conn,可把已读取的帧重新交给后续 Recv。
|
||||
//
|
||||
// 用于密钥交换:serveConn 已读首帧用于 peek auth_key_id,再 push 回来交给 exchange。
|
||||
type bufferedConn struct {
|
||||
transport.Conn
|
||||
mu sync.Mutex
|
||||
pending []bin.Buffer
|
||||
last bin.Buffer
|
||||
}
|
||||
|
||||
func newBufferedConn(conn transport.Conn) *bufferedConn {
|
||||
return &bufferedConn{Conn: conn}
|
||||
}
|
||||
|
||||
func (c *bufferedConn) push(b *bin.Buffer) {
|
||||
c.mu.Lock()
|
||||
c.pending = append(c.pending, bin.Buffer{Buf: b.Copy()})
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Recv 优先返回已 push 的帧(FIFO),耗尽后读取底层连接。
|
||||
func (c *bufferedConn) Recv(ctx context.Context, b *bin.Buffer) error {
|
||||
c.mu.Lock()
|
||||
if len(c.pending) > 0 {
|
||||
e := c.pending[0]
|
||||
c.pending = c.pending[1:]
|
||||
c.last.ResetTo(e.Copy())
|
||||
c.mu.Unlock()
|
||||
b.ResetTo(e.Buf)
|
||||
return nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if err := c.Conn.Recv(ctx, b); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.last.ResetTo(b.Copy())
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *bufferedConn) lastFrame() *bin.Buffer {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.last.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return &bin.Buffer{Buf: c.last.Copy()}
|
||||
}
|
||||
171
internal/mtprotoedge/exchange_test.go
Normal file
171
internal/mtprotoedge/exchange_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mt"
|
||||
tgproto "github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestKeyExchange 验证 M1:client 用 server 公钥完成 MTProto 密钥交换,
|
||||
// 双方得到一致的 auth key 与 server salt,且 server 将其存入 AuthKeyStore。
|
||||
func TestKeyExchange(t *testing.T) {
|
||||
const dc = 2
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen rsa: %v", err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
keys := memory.NewAuthKeyStore()
|
||||
srv := New(Options{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DC: dc,
|
||||
RSAKey: rsaKey,
|
||||
AuthKeys: keys,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
// client:TCP 拨号 + intermediate 握手,跑 client 端密钥交换。
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn, err := transport.Intermediate.Handshake(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transport handshake: %v", err)
|
||||
}
|
||||
|
||||
pub := exchange.PublicKey{RSA: &rsaKey.PublicKey}
|
||||
exchCtx, ec := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer ec()
|
||||
res, err := exchange.NewExchanger(conn, dc).
|
||||
WithRand(rand.Reader).
|
||||
WithLogger(zaptest.NewLogger(t).Named("client")).
|
||||
Client([]exchange.PublicKey{pub}).
|
||||
Run(exchCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("client exchange: %v", err)
|
||||
}
|
||||
|
||||
// server 在 Run 返回后落库,轮询等待。
|
||||
var saved store.AuthKeyData
|
||||
found := false
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
saved, found, _ = keys.Get(context.Background(), res.AuthKey.ID)
|
||||
if found {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("server did not store auth key %x", res.AuthKey.ID)
|
||||
}
|
||||
if saved.Value != [256]byte(res.AuthKey.Value) {
|
||||
t.Fatal("server auth key value mismatch")
|
||||
}
|
||||
if saved.ServerSalt != res.ServerSalt {
|
||||
t.Fatalf("server salt mismatch: server=%d client=%d", saved.ServerSalt, res.ServerSalt)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("serve: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server did not stop after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectFakeReqPQThenEncryptedFrame(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc})
|
||||
|
||||
firstConn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
_ = firstConn.Close()
|
||||
|
||||
raw, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial reconnect: %v", err)
|
||||
}
|
||||
conn, err := transport.Intermediate.Handshake(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transport reconnect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
var reqPayload bin.Buffer
|
||||
nonce, err := randInt128ForTest()
|
||||
if err != nil {
|
||||
t.Fatalf("nonce: %v", err)
|
||||
}
|
||||
if err := (&mt.ReqPqMultiRequest{Nonce: nonce}).Encode(&reqPayload); err != nil {
|
||||
t.Fatalf("encode req_pq_multi: %v", err)
|
||||
}
|
||||
var fakeReq bin.Buffer
|
||||
if err := (tgproto.UnencryptedMessage{
|
||||
MessageID: int64(tgproto.NewMessageID(time.Now(), tgproto.MessageFromClient)),
|
||||
MessageData: reqPayload.Raw(),
|
||||
}).Encode(&fakeReq); err != nil {
|
||||
t.Fatalf("encode fake req_pq: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := conn.Send(ctx, &fakeReq); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("send fake req_pq: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
msgGen := tgproto.NewMessageIDGen(time.Now)
|
||||
sendEncrypted(t, conn, cipher, auth, msgGen.New(tgproto.MessageFromClient), &mt.PingRequest{PingID: 7})
|
||||
|
||||
var resPQFrame bin.Buffer
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = conn.Recv(ctx, &resPQFrame)
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Fatalf("recv resPQ: %v", err)
|
||||
}
|
||||
var plain tgproto.UnencryptedMessage
|
||||
if err := plain.Decode(&resPQFrame); err != nil {
|
||||
t.Fatalf("decode resPQ frame: %v", err)
|
||||
}
|
||||
if id, err := (&bin.Buffer{Buf: plain.MessageData}).PeekID(); err != nil || id != mt.ResPQTypeID {
|
||||
t.Fatalf("resPQ payload id = %#x err=%v, want %#x", id, err, mt.ResPQTypeID)
|
||||
}
|
||||
|
||||
got := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
|
||||
mustHave(t, got, mt.PongTypeID, "pong after fake req_pq reconnect")
|
||||
}
|
||||
|
||||
func randInt128ForTest() (v bin.Int128, err error) {
|
||||
_, err = rand.Read(v[:])
|
||||
return v, err
|
||||
}
|
||||
209
internal/mtprotoedge/helpers_test.go
Normal file
209
internal/mtprotoedge/helpers_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/transport"
|
||||
)
|
||||
|
||||
// startTestServer 生成 RSA key、监听随机端口并启动 Server,返回监听地址与公钥。
|
||||
// 通过 t.Cleanup 自动取消并校验优雅退出。opts 的 RSAKey/Logger/DC 会被补默认。
|
||||
func startTestServer(t *testing.T, opts Options) (addr string, pub exchange.PublicKey, srv *Server) {
|
||||
t.Helper()
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen rsa: %v", err)
|
||||
}
|
||||
opts.RSAKey = rsaKey
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = zaptest.NewLogger(t)
|
||||
}
|
||||
if opts.DC == 0 {
|
||||
opts.DC = 2
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
srv = New(opts)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop after ctx cancel")
|
||||
}
|
||||
})
|
||||
|
||||
return ln.Addr().String(), exchange.PublicKey{RSA: &rsaKey.PublicKey}, srv
|
||||
}
|
||||
|
||||
// dialHandshake 建立 TCP 连接、完成 intermediate 协商与 MTProto 密钥交换,
|
||||
// 返回连接、握手结果与 client 端 cipher。连接通过 t.Cleanup 自动关闭。
|
||||
func dialHandshake(t *testing.T, addr string, dc int, pub exchange.PublicKey) (transport.Conn, exchange.ClientExchangeResult, crypto.Cipher) {
|
||||
t.Helper()
|
||||
raw, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn, err := transport.Intermediate.Handshake(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transport handshake: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
auth, err := exchange.NewExchanger(conn, dc).
|
||||
WithRand(rand.Reader).
|
||||
WithLogger(zaptest.NewLogger(t).Named("client")).
|
||||
Client([]exchange.PublicKey{pub}).
|
||||
Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("client exchange: %v", err)
|
||||
}
|
||||
return conn, auth, crypto.NewClientCipher(rand.Reader)
|
||||
}
|
||||
|
||||
// sendEncrypted 用 client cipher 加密并发送一条带 msgID 的消息。
|
||||
func sendEncrypted(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, msg bin.Encoder) {
|
||||
t.Helper()
|
||||
sendEncryptedWithSalt(t, conn, cipher, auth, auth.ServerSalt, msgID, msg)
|
||||
}
|
||||
|
||||
// sendEncryptedWithSalt 用指定 salt 加密并发送一条消息。
|
||||
func sendEncryptedWithSalt(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, salt, msgID int64, msg bin.Encoder) {
|
||||
t.Helper()
|
||||
body, seqNo := encodeClientMessageForTest(t, msg)
|
||||
sendEncryptedWithSaltAndSeq(t, conn, cipher, auth, salt, msgID, seqNo, body)
|
||||
}
|
||||
|
||||
func sendEncryptedWithSeq(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, seqNo int32, msg bin.Encoder) {
|
||||
t.Helper()
|
||||
body := encodeClientMessageBodyForTest(t, msg)
|
||||
sendEncryptedWithSaltAndSeq(t, conn, cipher, auth, auth.ServerSalt, msgID, seqNo, body)
|
||||
}
|
||||
|
||||
func sendEncryptedWithSaltAndSeq(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, salt, msgID int64, seqNo int32, body []byte) {
|
||||
t.Helper()
|
||||
var buf bin.Buffer
|
||||
if err := cipher.Encrypt(auth.AuthKey, crypto.EncryptedMessageData{
|
||||
Salt: salt,
|
||||
SessionID: auth.SessionID,
|
||||
MessageID: msgID,
|
||||
SeqNo: seqNo,
|
||||
MessageDataLen: int32(len(body)),
|
||||
MessageDataWithPadding: body,
|
||||
}, &buf); err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.Send(ctx, &buf); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeClientMessageForTest(t *testing.T, msg bin.Encoder) ([]byte, int32) {
|
||||
t.Helper()
|
||||
raw := encodeClientMessageBodyForTest(t, msg)
|
||||
typeID, err := (&bin.Buffer{Buf: raw}).PeekID()
|
||||
if err != nil {
|
||||
t.Fatalf("peek encrypted message type: %v", err)
|
||||
}
|
||||
if container, ok := msg.(*proto.MessageContainer); ok {
|
||||
return raw, clientContainerSeqNoForTest(container)
|
||||
}
|
||||
if clientMessageNeedsAck(typeID) {
|
||||
return raw, 1
|
||||
}
|
||||
return raw, 0
|
||||
}
|
||||
|
||||
func encodeClientMessageBodyForTest(t *testing.T, msg bin.Encoder) []byte {
|
||||
t.Helper()
|
||||
var body bin.Buffer
|
||||
if err := msg.Encode(&body); err != nil {
|
||||
t.Fatalf("encode encrypted message: %v", err)
|
||||
}
|
||||
return body.Copy()
|
||||
}
|
||||
|
||||
func clientContainerSeqNoForTest(container *proto.MessageContainer) int32 {
|
||||
var maxSeq int32
|
||||
for _, msg := range container.Messages {
|
||||
if seq := int32(msg.SeqNo); seq > maxSeq {
|
||||
maxSeq = seq
|
||||
}
|
||||
}
|
||||
if maxSeq%2 != 0 {
|
||||
maxSeq++
|
||||
}
|
||||
return maxSeq
|
||||
}
|
||||
|
||||
// collectReplies 读取并解密 server 回发的消息,按 TypeID 收集明文 buffer,
|
||||
// 直到见到 wantID(含)或达到上限。用于断言一次请求触发的多条响应
|
||||
// (new_session_created / 业务响应 / msgs_ack)。
|
||||
func collectReplies(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey, wantID uint32) map[uint32]*bin.Buffer {
|
||||
t.Helper()
|
||||
got := make(map[uint32]*bin.Buffer)
|
||||
for i := 0; i < 8; i++ {
|
||||
_, id, plain := readServerMessage(t, conn, cipher, key)
|
||||
got[id] = plain
|
||||
if id == wantID {
|
||||
break
|
||||
}
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func readServerMessage(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) (*crypto.EncryptedMessageData, uint32, *bin.Buffer) {
|
||||
t.Helper()
|
||||
var buf bin.Buffer
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err := conn.Recv(ctx, &buf)
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Fatalf("recv server message: %v", err)
|
||||
}
|
||||
data, err := cipher.DecryptFromBuffer(key, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt server message: %v", err)
|
||||
}
|
||||
plain := append([]byte(nil), data.Data()...)
|
||||
id, err := (&bin.Buffer{Buf: plain}).PeekID()
|
||||
if err != nil {
|
||||
t.Fatalf("peek server message: %v", err)
|
||||
}
|
||||
return data, id, &bin.Buffer{Buf: plain}
|
||||
}
|
||||
|
||||
// mustHave 断言 replies 含指定 TypeID 的消息并返回其 buffer。
|
||||
func mustHave(t *testing.T, replies map[uint32]*bin.Buffer, id uint32, name string) *bin.Buffer {
|
||||
t.Helper()
|
||||
b, ok := replies[id]
|
||||
if !ok {
|
||||
t.Fatalf("missing %s (%#x)", name, id)
|
||||
}
|
||||
return b
|
||||
}
|
||||
195
internal/mtprotoedge/inbound_rpc.go
Normal file
195
internal/mtprotoedge/inbound_rpc.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInboundRPCQueueFull 表示单连接 RPC 队列已满。
|
||||
var ErrInboundRPCQueueFull = errors.New("inbound rpc queue full")
|
||||
|
||||
// maxInflightRPCBytes 是单连接已入队未完成 inbound RPC body 的总字节上限。
|
||||
// 队列除按条数(queueSize)限制外,再按字节预算兜底:对抗客户端发满大请求时按字节先拒绝。
|
||||
const maxInflightRPCBytes = 32 << 20 // 32 MiB
|
||||
|
||||
// rpcCloseWaitTimeout 是连接关闭时等待 inbound RPC worker 退出的上限。
|
||||
const rpcCloseWaitTimeout = 5 * time.Second
|
||||
|
||||
type inboundRPC struct {
|
||||
ctx context.Context
|
||||
method string
|
||||
enqueuedAt time.Time
|
||||
size int
|
||||
run func(context.Context) error
|
||||
}
|
||||
|
||||
func (c *Conn) startInboundRPCScheduler(maxInflight, queueSize int, timeout time.Duration) {
|
||||
if c.metrics == nil {
|
||||
c.metrics = NopMetrics{}
|
||||
}
|
||||
if maxInflight <= 0 {
|
||||
maxInflight = 1
|
||||
}
|
||||
if queueSize <= 0 {
|
||||
queueSize = 1
|
||||
}
|
||||
rootCtx, cancel := context.WithCancel(context.Background())
|
||||
c.rpcQueue = make(chan inboundRPC, queueSize)
|
||||
c.rpcStop = make(chan struct{})
|
||||
c.rpcCancel = cancel
|
||||
c.rpcTimeout = timeout
|
||||
c.rpcRootCtx = rootCtx
|
||||
c.rpcMaxInflight = maxInflight
|
||||
// worker 懒启动:不在此处起 worker;首个 RPC 入队时由 ensureInboundRPCWorkers 起,
|
||||
// 避免握手后静默 / 纯推送目标连接白白钉住 maxInflight 个 goroutine。
|
||||
}
|
||||
|
||||
// ensureInboundRPCWorkers 懒启动 maxInflight 个 RPC worker(仅一次),在 enqueueInboundRPC
|
||||
// 入队成功后调用。从不发 RPC 的连接(半开 / 纯推送)由此完全不起 worker。
|
||||
func (c *Conn) ensureInboundRPCWorkers() {
|
||||
c.rpcWorkersOnce.Do(func() {
|
||||
c.rpcWG.Add(c.rpcMaxInflight)
|
||||
for i := 0; i < c.rpcMaxInflight; i++ {
|
||||
go c.inboundRPCWorker(c.rpcRootCtx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Conn) enqueueInboundRPC(ctx context.Context, task inboundRPC) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if c.rpcQueue == nil || c.rpcStop == nil {
|
||||
c.metrics.InboundRPCDropped(task.method, "scheduler_closed")
|
||||
return ErrConnClosed
|
||||
}
|
||||
task.ctx = ctx
|
||||
task.enqueuedAt = time.Now()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.metrics.InboundRPCDropped(task.method, "context_done")
|
||||
return ctx.Err()
|
||||
case <-c.rpcStop:
|
||||
c.metrics.InboundRPCDropped(task.method, "scheduler_closed")
|
||||
return ErrConnClosed
|
||||
default:
|
||||
}
|
||||
// 字节预算:先预扣 size,超 maxInflightRPCBytes 则回滚并拒绝(与条数上限并列的第二道闸)。
|
||||
if task.size > 0 {
|
||||
if c.inflightRPCBytes.Add(int64(task.size)) > maxInflightRPCBytes {
|
||||
c.inflightRPCBytes.Add(-int64(task.size))
|
||||
c.metrics.InboundRPCDropped(task.method, "byte_budget")
|
||||
return ErrInboundRPCQueueFull
|
||||
}
|
||||
}
|
||||
select {
|
||||
case c.rpcQueue <- task:
|
||||
c.ensureInboundRPCWorkers()
|
||||
c.metrics.InboundRPCQueued(task.method, len(c.rpcQueue), cap(c.rpcQueue))
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
c.releaseInflightRPCBytes(task.size)
|
||||
c.metrics.InboundRPCDropped(task.method, "context_done")
|
||||
return ctx.Err()
|
||||
case <-c.rpcStop:
|
||||
c.releaseInflightRPCBytes(task.size)
|
||||
c.metrics.InboundRPCDropped(task.method, "scheduler_closed")
|
||||
return ErrConnClosed
|
||||
default:
|
||||
c.releaseInflightRPCBytes(task.size)
|
||||
c.metrics.InboundRPCDropped(task.method, "queue_full")
|
||||
return ErrInboundRPCQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
// releaseInflightRPCBytes 归还字节预算。与 enqueueInboundRPC 的预扣严格配对:
|
||||
// 入队失败时回滚、worker 执行完(runInboundRPC)或排空丢弃(drainInboundRPCQueue)时释放。
|
||||
func (c *Conn) releaseInflightRPCBytes(size int) {
|
||||
if size > 0 {
|
||||
c.inflightRPCBytes.Add(-int64(size))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) inboundRPCWorker(rootCtx context.Context) {
|
||||
defer c.rpcWG.Done()
|
||||
for {
|
||||
select {
|
||||
case <-c.rpcStop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case task := <-c.rpcQueue:
|
||||
c.runInboundRPC(rootCtx, task)
|
||||
case <-c.rpcStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) runInboundRPC(rootCtx context.Context, task inboundRPC) {
|
||||
defer c.releaseInflightRPCBytes(task.size)
|
||||
queueWait := time.Since(task.enqueuedAt)
|
||||
c.metrics.InboundRPCStarted(task.method, queueWait)
|
||||
ctx := task.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
stopRoot := context.AfterFunc(rootCtx, cancel)
|
||||
defer stopRoot()
|
||||
if c.rpcTimeout > 0 {
|
||||
var timeoutCancel context.CancelFunc
|
||||
ctx, timeoutCancel = context.WithTimeout(ctx, c.rpcTimeout)
|
||||
defer timeoutCancel()
|
||||
}
|
||||
_ = task.run(ctx)
|
||||
}
|
||||
|
||||
func (c *Conn) closeInboundRPCScheduler() {
|
||||
if c.rpcStop == nil {
|
||||
return
|
||||
}
|
||||
c.rpcClose.Do(func() {
|
||||
if c.rpcCancel != nil {
|
||||
c.rpcCancel()
|
||||
}
|
||||
close(c.rpcStop)
|
||||
// 抢占懒启动 Once:若 worker 尚未起,封住其启动,避免后续 ensureInboundRPCWorkers 的
|
||||
// rpcWG.Add 与下面的 rpcWG.Wait 并发(WaitGroup 误用)。Once 互斥保证 Add happens-before Wait。
|
||||
c.rpcWorkersOnce.Do(func() {})
|
||||
c.drainInboundRPCQueue()
|
||||
// 等 worker 退出,使关闭对 inbound 与 outbound(<-outboundDone)收敛对称;带超时防慢 handler 卡死。
|
||||
c.waitInboundWorkers(rpcCloseWaitTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
// waitInboundWorkers 等所有 inbound RPC worker 退出,最长 timeout。超时则放弃等待,
|
||||
// worker 在其阻塞的底层调用返回后自行退出(rpcCancel 已发,最终收敛)。
|
||||
func (c *Conn) waitInboundWorkers(timeout time.Duration) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.rpcWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) drainInboundRPCQueue() {
|
||||
for {
|
||||
select {
|
||||
case task := <-c.rpcQueue:
|
||||
c.releaseInflightRPCBytes(task.size)
|
||||
c.metrics.InboundRPCDropped(task.method, "connection_closed")
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/mtprotoedge/inbound_rpc_test.go
Normal file
76
internal/mtprotoedge/inbound_rpc_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInboundRPCSchedulerBoundsConcurrentWork(t *testing.T) {
|
||||
c := &Conn{metrics: NopMetrics{}}
|
||||
c.startInboundRPCScheduler(2, 4, time.Second)
|
||||
defer c.closeInboundRPCScheduler()
|
||||
|
||||
var active atomic.Int64
|
||||
var maxActive atomic.Int64
|
||||
var done atomic.Int64
|
||||
started := make(chan struct{}, 6)
|
||||
release := make(chan struct{})
|
||||
task := inboundRPC{
|
||||
method: "test.method",
|
||||
run: func(ctx context.Context) error {
|
||||
cur := active.Add(1)
|
||||
for {
|
||||
old := maxActive.Load()
|
||||
if cur <= old || maxActive.CompareAndSwap(old, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
started <- struct{}{}
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
active.Add(-1)
|
||||
done.Add(1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := c.enqueueInboundRPC(context.Background(), task); err != nil {
|
||||
t.Fatalf("enqueue active task %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for active rpc workers")
|
||||
}
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := c.enqueueInboundRPC(context.Background(), task); err != nil {
|
||||
t.Fatalf("enqueue queued task %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := c.enqueueInboundRPC(context.Background(), task); !errors.Is(err, ErrInboundRPCQueueFull) {
|
||||
t.Fatalf("enqueue over capacity err = %v, want ErrInboundRPCQueueFull", err)
|
||||
}
|
||||
if got := maxActive.Load(); got != 2 {
|
||||
t.Fatalf("max active = %d, want 2", got)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.After(2 * time.Second)
|
||||
for done.Load() != 6 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("done = %d, want 6", done.Load())
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
457
internal/mtprotoedge/login_e2e_test.go
Normal file
457
internal/mtprotoedge/login_e2e_test.go
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/session"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/app/account"
|
||||
"telesrv/internal/app/auth"
|
||||
"telesrv/internal/app/contacts"
|
||||
"telesrv/internal/app/dialogs"
|
||||
"telesrv/internal/app/help"
|
||||
"telesrv/internal/app/langpack"
|
||||
messageapp "telesrv/internal/app/messages"
|
||||
"telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestLoginRegisterFlow 是登录注册闭环的端到端验证:telegram.Client 连本地 server,
|
||||
// 依次 sendCode → signIn(需注册) → signUp → getUsers(self),验证注册后能用 self 查回自己。
|
||||
func TestLoginRegisterFlow(t *testing.T) {
|
||||
const (
|
||||
dc = 2
|
||||
phone = "+8613800138000"
|
||||
wantPhone = "8613800138000"
|
||||
code = "12345"
|
||||
)
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen rsa: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tcpAddr := ln.Addr().(*net.TCPAddr)
|
||||
|
||||
userStore := memory.NewUserStore()
|
||||
authzStore := memory.NewAuthorizationStore()
|
||||
authKeyStore := memory.NewAuthKeyStore()
|
||||
helpStore := memory.NewHelpStore()
|
||||
if err := helpStore.UpsertAppConfig(context.Background(), domain.AppConfig{
|
||||
Client: "tdesktop",
|
||||
Hash: 4,
|
||||
JSON: []byte(`{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373"}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed app config: %v", err)
|
||||
}
|
||||
if err := helpStore.UpsertCountries(context.Background(), []domain.Country{
|
||||
{ISO2: "US", DefaultName: "United States", CountryCodes: []domain.CountryCode{{CountryCode: "1", Prefixes: []string{"1"}}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed countries: %v", err)
|
||||
}
|
||||
langPackStore := memory.NewLangPackStore()
|
||||
if err := langPackStore.UpsertPack(context.Background(), domain.LangPack{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: "en",
|
||||
Version: 1,
|
||||
Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "English"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed langpack: %v", err)
|
||||
}
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
|
||||
Contacts: contacts.NewService(memory.NewContactStore()),
|
||||
Dialogs: dialogs.NewService(memory.NewDialogStore()),
|
||||
LangPack: langpack.NewService(langPackStore),
|
||||
}
|
||||
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
|
||||
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
opts := telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
|
||||
Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
|
||||
DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
|
||||
Logger: zaptest.NewLogger(t).Named("client"),
|
||||
SessionStorage: &session.StorageMemory{},
|
||||
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
|
||||
}
|
||||
client := telegram.NewClient(1, "hash", opts)
|
||||
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
|
||||
// 1) sendCode → phone_code_hash
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
|
||||
PhoneNumber: phone,
|
||||
APIID: 1,
|
||||
APIHash: "hash",
|
||||
Settings: tg.CodeSettings{},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sentCode, ok := sent.(*tg.AuthSentCode)
|
||||
if !ok {
|
||||
t.Fatalf("sendCode result = %T, want *tg.AuthSentCode", sent)
|
||||
}
|
||||
hash := sentCode.PhoneCodeHash
|
||||
|
||||
// 2) signIn → 新用户应得 SignUpRequired
|
||||
signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
|
||||
PhoneNumber: phone,
|
||||
PhoneCodeHash: hash,
|
||||
PhoneCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := signInRes.(*tg.AuthAuthorizationSignUpRequired); !ok {
|
||||
t.Fatalf("signIn result = %T, want *tg.AuthAuthorizationSignUpRequired", signInRes)
|
||||
}
|
||||
|
||||
// 3) signUp → 创建用户并返回授权
|
||||
signUpRes, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{
|
||||
PhoneNumber: phone,
|
||||
PhoneCodeHash: hash,
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authz, ok := signUpRes.(*tg.AuthAuthorization)
|
||||
if !ok {
|
||||
t.Fatalf("signUp result = %T, want *tg.AuthAuthorization", signUpRes)
|
||||
}
|
||||
newUser, ok := authz.User.(*tg.User)
|
||||
if !ok {
|
||||
t.Fatalf("signUp user = %T, want *tg.User", authz.User)
|
||||
}
|
||||
if !newUser.Self || newUser.FirstName != "Test" || newUser.Phone != wantPhone {
|
||||
t.Fatalf("signUp user = %+v, want self FirstName=Test Phone=%s", newUser, wantPhone)
|
||||
}
|
||||
|
||||
// 4) getUsers(self) → 注册后能查回自己
|
||||
got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("getUsers returned %d users, want 1", len(got))
|
||||
}
|
||||
self, ok := got[0].(*tg.User)
|
||||
if !ok {
|
||||
t.Fatalf("getUsers[0] = %T, want *tg.User", got[0])
|
||||
}
|
||||
if self.ID != newUser.ID || self.FirstName != "Test" || self.Phone != wantPhone {
|
||||
t.Fatalf("getUsers self = %+v, want id=%d FirstName=Test Phone=%s", self, newUser.ID, wantPhone)
|
||||
}
|
||||
|
||||
// 5) 启动配置、账号安全和登录后的空账号 RPC 走业务服务并可编码。
|
||||
appConfig, err := raw.HelpGetAppConfig(ctx, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg, ok := appConfig.(*tg.HelpAppConfig); !ok || cfg.Hash != 4 {
|
||||
t.Fatalf("help.getAppConfig = %T %+v, want hash=4 config", appConfig, appConfig)
|
||||
}
|
||||
countriesRes, err := raw.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: "en"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if countries, ok := countriesRes.(*tg.HelpCountriesList); !ok || len(countries.Countries) != 1 {
|
||||
t.Fatalf("help.getCountriesList = %T %+v, want 1 country", countriesRes, countriesRes)
|
||||
}
|
||||
password, err := raw.AccountGetPassword(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if password.HasPassword || len(password.SecureRandom) == 0 {
|
||||
t.Fatalf("account.getPassword = %+v, want no password with secure random", password)
|
||||
}
|
||||
state, err := raw.UpdatesGetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.Date == 0 {
|
||||
t.Fatal("updates.getState Date is zero")
|
||||
}
|
||||
diff, err := raw.UpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{
|
||||
Pts: state.Pts,
|
||||
Date: state.Date,
|
||||
Qts: state.Qts,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok {
|
||||
t.Fatalf("updates.getDifference = %T, want *tg.UpdatesDifferenceEmpty", diff)
|
||||
}
|
||||
contactsRes, err := raw.ContactsGetContacts(ctx, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contacts, ok := contactsRes.(*tg.ContactsContacts); !ok || len(contacts.Contacts) != 0 {
|
||||
t.Fatalf("contacts.getContacts = %T %+v, want empty *tg.ContactsContacts", contactsRes, contactsRes)
|
||||
}
|
||||
dialogsRes, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
|
||||
OffsetPeer: &tg.InputPeerEmpty{},
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dialogs, ok := dialogsRes.(*tg.MessagesDialogs); !ok || len(dialogs.Dialogs) != 0 {
|
||||
t.Fatalf("messages.getDialogs = %T %+v, want empty *tg.MessagesDialogs", dialogsRes, dialogsRes)
|
||||
}
|
||||
pinned, err := raw.MessagesGetPinnedDialogs(ctx, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pinned.Dialogs) != 0 || pinned.State.Date == 0 {
|
||||
t.Fatalf("messages.getPinnedDialogs = %+v, want empty dialogs with state", pinned)
|
||||
}
|
||||
pack, err := raw.LangpackGetLangPack(ctx, &tg.LangpackGetLangPackRequest{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: "en",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pack.Version != 1 || len(pack.Strings) != 1 {
|
||||
t.Fatalf("langpack.getLangPack = %+v, want version 1 with 1 string", pack)
|
||||
}
|
||||
strings, err := raw.LangpackGetStrings(ctx, &tg.LangpackGetStringsRequest{
|
||||
LangPack: "tdesktop",
|
||||
LangCode: "en",
|
||||
Keys: []string{"lng_language_name"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(strings) != 1 {
|
||||
t.Fatalf("langpack.getStrings returned %d strings, want 1", len(strings))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("login/register flow: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateMessageRoundTripFlow(t *testing.T) {
|
||||
const (
|
||||
dc = 2
|
||||
code = "12345"
|
||||
)
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen rsa: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tcpAddr := ln.Addr().(*net.TCPAddr)
|
||||
|
||||
userStore := memory.NewUserStore()
|
||||
authzStore := memory.NewAuthorizationStore()
|
||||
authKeyStore := memory.NewAuthKeyStore()
|
||||
helpStore := memory.NewHelpStore()
|
||||
langPackStore := memory.NewLangPackStore()
|
||||
dialogStore := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogStore)
|
||||
activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
|
||||
Contacts: contacts.NewService(memory.NewContactStore()),
|
||||
Dialogs: dialogs.NewService(dialogStore),
|
||||
Messages: messageapp.NewService(messageStore, dialogStore),
|
||||
LangPack: langpack.NewService(langPackStore),
|
||||
Sessions: activeSessions,
|
||||
}
|
||||
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
|
||||
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
newClient := func(storage *session.StorageMemory) *telegram.Client {
|
||||
opts := telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
|
||||
Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
|
||||
DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
|
||||
Logger: zaptest.NewLogger(t).Named("client"),
|
||||
SessionStorage: storage,
|
||||
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
|
||||
}
|
||||
return telegram.NewClient(1, "hash", opts)
|
||||
}
|
||||
storageA := &session.StorageMemory{}
|
||||
storageB := &session.StorageMemory{}
|
||||
|
||||
messagesOf := func(history tg.MessagesMessagesClass) []tg.MessageClass {
|
||||
t.Helper()
|
||||
switch v := history.(type) {
|
||||
case *tg.MessagesMessages:
|
||||
return v.Messages
|
||||
case *tg.MessagesMessagesSlice:
|
||||
return v.Messages
|
||||
default:
|
||||
t.Fatalf("history = %T %+v, want messages", history, history)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
signUp := func(storage *session.StorageMemory, phone, firstName string) tg.User {
|
||||
t.Helper()
|
||||
client := newClient(storage)
|
||||
var out tg.User
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
|
||||
PhoneNumber: phone,
|
||||
APIID: 1,
|
||||
APIHash: "hash",
|
||||
Settings: tg.CodeSettings{},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sent.(*tg.AuthSentCode).PhoneCodeHash
|
||||
if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
|
||||
PhoneNumber: phone,
|
||||
PhoneCodeHash: hash,
|
||||
PhoneCode: code,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{
|
||||
PhoneNumber: phone,
|
||||
PhoneCodeHash: hash,
|
||||
FirstName: firstName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authz := res.(*tg.AuthAuthorization)
|
||||
u := authz.User.(*tg.User)
|
||||
out = *u
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("signUp %s: %v", firstName, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
userA := signUp(storageA, "+15550001001", "Alice")
|
||||
userB := signUp(storageB, "+15550001002", "Bob")
|
||||
|
||||
sendAndRead := func(storage *session.StorageMemory, to tg.User, body string, randomID int64) {
|
||||
t.Helper()
|
||||
client := newClient(storage)
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
updates, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash},
|
||||
Message: body,
|
||||
RandomID: randomID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotUpdates, ok := updates.(*tg.Updates)
|
||||
if !ok || len(gotUpdates.Updates) < 2 {
|
||||
t.Fatalf("send updates = %T %+v, want message id + new message", updates, updates)
|
||||
}
|
||||
history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := messagesOf(history)
|
||||
if len(msgs) == 0 {
|
||||
t.Fatalf("history = %T %+v, want messages", history, history)
|
||||
}
|
||||
msg, ok := msgs[0].(*tg.Message)
|
||||
if !ok || msg.Message != body || !msg.Out {
|
||||
t.Fatalf("latest history message = %#v, want outgoing %q", msgs[0], body)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("send %q: %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
sendAndRead(storageA, userB, "hello bob", 1001)
|
||||
|
||||
clientB := newClient(storageB)
|
||||
if err := clientB.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(clientB)
|
||||
history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: userA.ID, AccessHash: userA.AccessHash},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := messagesOf(history)
|
||||
if len(msgs) == 0 {
|
||||
t.Fatalf("bob history = %T %+v, want incoming message", history, history)
|
||||
}
|
||||
msg, ok := msgs[0].(*tg.Message)
|
||||
if !ok || msg.Message != "hello bob" || msg.Out {
|
||||
t.Fatalf("bob latest message = %#v, want incoming hello bob", msgs[0])
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("bob read incoming: %v", err)
|
||||
}
|
||||
|
||||
sendAndRead(storageB, userA, "hi alice", 2001)
|
||||
|
||||
cancel()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
67
internal/mtprotoedge/metrics.go
Normal file
67
internal/mtprotoedge/metrics.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package mtprotoedge
|
||||
|
||||
import "time"
|
||||
|
||||
// Metrics 接收连接层运行指标。实现可对接 Prometheus 等监控系统;
|
||||
// 默认 NopMetrics(零开销)。第一阶段仅预留钩子,正式指标后续接入。
|
||||
type Metrics interface {
|
||||
// ConnOpened 在接受一个连接时调用。
|
||||
ConnOpened()
|
||||
// ConnClosed 在一个连接结束时调用。
|
||||
ConnClosed()
|
||||
// HandshakeDone 在一次密钥交换成功完成时调用,d 为握手耗时。
|
||||
HandshakeDone(d time.Duration)
|
||||
// RPCHandled 在一次 RPC 处理完成时调用:method 为 TL 方法名,
|
||||
// d 为耗时,err 非 nil 表示失败。
|
||||
RPCHandled(method string, d time.Duration, err error)
|
||||
// InboundRPCQueued 在 RPC 成功进入单连接 bounded queue 时调用。
|
||||
InboundRPCQueued(method string, len, cap int)
|
||||
// InboundRPCStarted 在 RPC 从 bounded queue 取出开始执行时调用。
|
||||
InboundRPCStarted(method string, queueWait time.Duration)
|
||||
// InboundRPCDropped 在 RPC 因队列满、连接关闭或调度错误被丢弃时调用。
|
||||
InboundRPCDropped(method, reason string)
|
||||
// OutboundSend 在一条 server 出站消息完成写入或失败时调用。
|
||||
OutboundSend(typeID uint32, queueWait time.Duration, bytes int, err error)
|
||||
// OutboundResend 在一次 msg_resend_req/重复 RPC 触发重发后调用。
|
||||
OutboundResend(count int, err error)
|
||||
// OutboundDropped 在出站队列或状态跟踪因背压丢弃时调用。
|
||||
OutboundDropped(reason string)
|
||||
// OutboundQueueWait 在出站入队等待超过阈值时调用。
|
||||
OutboundQueueWait(len, cap int)
|
||||
}
|
||||
|
||||
// NopMetrics 是 Metrics 的空实现。
|
||||
type NopMetrics struct{}
|
||||
|
||||
// ConnOpened 实现 Metrics。
|
||||
func (NopMetrics) ConnOpened() {}
|
||||
|
||||
// ConnClosed 实现 Metrics。
|
||||
func (NopMetrics) ConnClosed() {}
|
||||
|
||||
// HandshakeDone 实现 Metrics。
|
||||
func (NopMetrics) HandshakeDone(time.Duration) {}
|
||||
|
||||
// RPCHandled 实现 Metrics。
|
||||
func (NopMetrics) RPCHandled(string, time.Duration, error) {}
|
||||
|
||||
// InboundRPCQueued 实现 Metrics。
|
||||
func (NopMetrics) InboundRPCQueued(string, int, int) {}
|
||||
|
||||
// InboundRPCStarted 实现 Metrics。
|
||||
func (NopMetrics) InboundRPCStarted(string, time.Duration) {}
|
||||
|
||||
// InboundRPCDropped 实现 Metrics。
|
||||
func (NopMetrics) InboundRPCDropped(string, string) {}
|
||||
|
||||
// OutboundSend 实现 Metrics。
|
||||
func (NopMetrics) OutboundSend(uint32, time.Duration, int, error) {}
|
||||
|
||||
// OutboundResend 实现 Metrics。
|
||||
func (NopMetrics) OutboundResend(int, error) {}
|
||||
|
||||
// OutboundDropped 实现 Metrics。
|
||||
func (NopMetrics) OutboundDropped(string) {}
|
||||
|
||||
// OutboundQueueWait 实现 Metrics。
|
||||
func (NopMetrics) OutboundQueueWait(int, int) {}
|
||||
73
internal/mtprotoedge/metrics_test.go
Normal file
73
internal/mtprotoedge/metrics_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"telesrv/internal/rpc"
|
||||
)
|
||||
|
||||
type countingMetrics struct {
|
||||
connOpened atomic.Int64
|
||||
connClosed atomic.Int64
|
||||
handshakes atomic.Int64
|
||||
rpcs atomic.Int64
|
||||
inbound atomic.Int64
|
||||
outbound atomic.Int64
|
||||
}
|
||||
|
||||
func (m *countingMetrics) ConnOpened() { m.connOpened.Add(1) }
|
||||
func (m *countingMetrics) ConnClosed() { m.connClosed.Add(1) }
|
||||
func (m *countingMetrics) HandshakeDone(time.Duration) { m.handshakes.Add(1) }
|
||||
func (m *countingMetrics) RPCHandled(string, time.Duration, error) { m.rpcs.Add(1) }
|
||||
func (m *countingMetrics) InboundRPCQueued(string, int, int) {}
|
||||
func (m *countingMetrics) InboundRPCStarted(string, time.Duration) { m.inbound.Add(1) }
|
||||
func (m *countingMetrics) InboundRPCDropped(string, string) {}
|
||||
func (m *countingMetrics) OutboundSend(uint32, time.Duration, int, error) {
|
||||
m.outbound.Add(1)
|
||||
}
|
||||
func (m *countingMetrics) OutboundResend(int, error) {}
|
||||
func (m *countingMetrics) OutboundDropped(string) {}
|
||||
func (m *countingMetrics) OutboundQueueWait(int, int) {}
|
||||
|
||||
// TestMetricsHooks 验证 M5:连接、握手、RPC 的 metrics 钩子被正确调用。
|
||||
func TestMetricsHooks(t *testing.T) {
|
||||
const dc = 2
|
||||
m := &countingMetrics{}
|
||||
router := rpc.New(rpc.Config{DC: dc, IP: "127.0.0.1", Port: 2398}, rpc.Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: router, Metrics: m})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &tg.HelpGetConfigRequest{})
|
||||
collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
|
||||
|
||||
if got := m.connOpened.Load(); got < 1 {
|
||||
t.Errorf("ConnOpened called %d times, want >= 1", got)
|
||||
}
|
||||
if got := m.handshakes.Load(); got != 1 {
|
||||
t.Errorf("HandshakeDone called %d times, want 1", got)
|
||||
}
|
||||
if got := m.rpcs.Load(); got != 1 {
|
||||
t.Errorf("RPCHandled called %d times, want 1", got)
|
||||
}
|
||||
if got := m.inbound.Load(); got != 1 {
|
||||
t.Errorf("InboundRPCStarted called %d times, want 1", got)
|
||||
}
|
||||
// new_session_created / ack 走 fire-and-forget(异步),可能在 client 收到 rpc_result 后
|
||||
// 才被 outbound actor 处理;轮询等其最终发送完成。M5 验证发送计数,不约束同步时序。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for m.outbound.Load() < 3 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := m.outbound.Load(); got < 3 {
|
||||
t.Errorf("OutboundSend called %d times, want >= 3", got)
|
||||
}
|
||||
}
|
||||
647
internal/mtprotoedge/outbound.go
Normal file
647
internal/mtprotoedge/outbound.go
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrConnClosed 表示连接的出站 actor 已关闭。
|
||||
ErrConnClosed = errors.New("mtproto connection closed")
|
||||
// ErrOutboundQueueFull 表示 best-effort update push 未能在预算内进入出站队列。
|
||||
ErrOutboundQueueFull = errors.New("mtproto outbound queue full")
|
||||
)
|
||||
|
||||
const (
|
||||
maxOutboundQueue = 1024
|
||||
maxTrackedServerMsgIDs = 4096
|
||||
maxTrackedAckedMsgIDs = 1024
|
||||
// maxTrackedServerBytes 是 pending(已发送待 ack、用于 resend)总 body 字节上限。
|
||||
// 与 maxTrackedServerMsgIDs 并列:客户端从不 ack 时,大响应体按字节滚动丢弃,
|
||||
// 防 pending 被「4096 条 × 大 body」撑爆。
|
||||
maxTrackedServerBytes = 64 << 20 // 64 MiB
|
||||
)
|
||||
|
||||
type outboundOpKind byte
|
||||
|
||||
const (
|
||||
outboundSend outboundOpKind = iota + 1
|
||||
outboundAck
|
||||
outboundQueryState
|
||||
outboundResend
|
||||
outboundResendByRequest
|
||||
)
|
||||
|
||||
type outboundOp struct {
|
||||
kind outboundOpKind
|
||||
control bool
|
||||
ctx context.Context
|
||||
msgType proto.MessageType
|
||||
msg bin.Encoder
|
||||
ids []int64
|
||||
reqMsgID int64
|
||||
enqueuedAt time.Time
|
||||
done chan outboundResult
|
||||
}
|
||||
|
||||
type outboundResult struct {
|
||||
info []byte
|
||||
resent bool
|
||||
err error
|
||||
}
|
||||
|
||||
type outboundFrame struct {
|
||||
msgID int64
|
||||
seqNo int32
|
||||
typeID uint32
|
||||
body []byte
|
||||
reqMsgID int64
|
||||
sentAt time.Time
|
||||
sends int
|
||||
}
|
||||
|
||||
type outboundState struct {
|
||||
pending map[int64]*outboundFrame
|
||||
order []int64
|
||||
byRequest map[int64]int64
|
||||
acked map[int64]struct{}
|
||||
ackOrder []int64
|
||||
totalBytes int
|
||||
}
|
||||
|
||||
func newOutboundState() *outboundState {
|
||||
return &outboundState{
|
||||
pending: make(map[int64]*outboundFrame),
|
||||
byRequest: make(map[int64]int64),
|
||||
acked: make(map[int64]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) startOutbound() {
|
||||
if c.metrics == nil {
|
||||
c.metrics = NopMetrics{}
|
||||
}
|
||||
c.outbound = make(chan outboundOp, maxOutboundQueue)
|
||||
c.outboundControl = make(chan outboundOp, maxOutboundQueue/4)
|
||||
c.outboundStop = make(chan struct{})
|
||||
c.outboundDone = make(chan struct{})
|
||||
go c.outboundLoop()
|
||||
}
|
||||
|
||||
// Close 停止连接的出站 actor。它不关闭底层 transport;transport 生命周期仍由 serveConn 管理。
|
||||
func (c *Conn) Close() {
|
||||
c.closeInboundRPCScheduler()
|
||||
c.outboundClose.Do(func() {
|
||||
if c.outboundStop != nil {
|
||||
close(c.outboundStop)
|
||||
<-c.outboundDone
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Send 加密并发送一条 server 消息。
|
||||
func (c *Conn) Send(ctx context.Context, t proto.MessageType, msg bin.Encoder) error {
|
||||
return c.send(ctx, t, msg, false)
|
||||
}
|
||||
|
||||
// SendPriority 加密并优先发送一条 server 控制消息。
|
||||
func (c *Conn) SendPriority(ctx context.Context, t proto.MessageType, msg bin.Encoder) error {
|
||||
return c.send(ctx, t, msg, true)
|
||||
}
|
||||
|
||||
// SendBestEffort 只等待消息进入普通 outbound 队列,不等待网络写完成。
|
||||
// 用于 updates fanout:队列拥塞时返回 ErrOutboundQueueFull,durable outbox/getDifference 负责兜底。
|
||||
func (c *Conn) SendBestEffort(ctx context.Context, t proto.MessageType, msg bin.Encoder, timeout time.Duration) error {
|
||||
if c.outbound == nil || c.outboundControl == nil {
|
||||
return ErrConnClosed
|
||||
}
|
||||
writeCtx := context.Background()
|
||||
if ctx != nil {
|
||||
writeCtx = context.WithoutCancel(ctx)
|
||||
}
|
||||
op := outboundOp{
|
||||
kind: outboundSend,
|
||||
ctx: writeCtx,
|
||||
msgType: t,
|
||||
msg: msg,
|
||||
enqueuedAt: time.Now(),
|
||||
}
|
||||
if timeout == 0 {
|
||||
select {
|
||||
case c.outbound <- op:
|
||||
return nil
|
||||
case <-c.outboundStop:
|
||||
return ErrConnClosed
|
||||
default:
|
||||
c.metrics.OutboundDropped("push_queue_full")
|
||||
return ErrOutboundQueueFull
|
||||
}
|
||||
}
|
||||
enqueueCtx := ctx
|
||||
if enqueueCtx == nil {
|
||||
enqueueCtx = context.Background()
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
if timeout > 0 {
|
||||
enqueueCtx, cancel = context.WithTimeout(enqueueCtx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
if err := c.enqueueOutbound(enqueueCtx, op); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) && timeout > 0 {
|
||||
c.metrics.OutboundDropped("push_queue_timeout")
|
||||
return ErrOutboundQueueFull
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) send(ctx context.Context, t proto.MessageType, msg bin.Encoder, control bool) error {
|
||||
if c.outbound == nil || c.outboundControl == nil {
|
||||
return ErrConnClosed
|
||||
}
|
||||
op := outboundOp{
|
||||
kind: outboundSend,
|
||||
control: control,
|
||||
ctx: ctx,
|
||||
msgType: t,
|
||||
msg: msg,
|
||||
enqueuedAt: time.Now(),
|
||||
done: make(chan outboundResult, 1),
|
||||
}
|
||||
if err := c.enqueueOutbound(ctx, op); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case res := <-op.done:
|
||||
return res.err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.outboundStop:
|
||||
return ErrConnClosed
|
||||
}
|
||||
}
|
||||
|
||||
// SendAsync 入队一条 server 消息但不等待发送结果(fire-and-forget),用于读循环里的控制消息
|
||||
// (ack/pong/new_session_created/bad_msg/future_salts/state_info):避免读循环被 outbound 写
|
||||
// 阻塞而连带卡死。走优先(control)队列保证不被普通 push 拖后;队列满时丢弃并记 metrics——此时
|
||||
// 连接多已严重拥塞,控制消息丢失由客户端重传 / 读写超时兜底。返回非 nil 仅表示连接已关闭。
|
||||
func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encoder) error {
|
||||
if c.outbound == nil || c.outboundControl == nil {
|
||||
return ErrConnClosed
|
||||
}
|
||||
op := outboundOp{
|
||||
kind: outboundSend,
|
||||
control: true,
|
||||
ctx: ctx,
|
||||
msgType: t,
|
||||
msg: msg,
|
||||
enqueuedAt: time.Now(),
|
||||
// done 为 nil:fire-and-forget,handleOutboundSend 的 finish 对 nil done 安全跳过。
|
||||
}
|
||||
select {
|
||||
case c.outboundControl <- op:
|
||||
return nil
|
||||
case <-c.outboundStop:
|
||||
return ErrConnClosed
|
||||
default:
|
||||
c.metrics.OutboundDropped("control_queue_full")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AckServerMessages 接收客户端 msgs_ack,释放已确认的 server 出站消息。
|
||||
func (c *Conn) AckServerMessages(ids []int64) {
|
||||
if len(ids) == 0 || c.outbound == nil || c.outboundControl == nil {
|
||||
return
|
||||
}
|
||||
copied := append([]int64(nil), ids...)
|
||||
op := outboundOp{kind: outboundAck, control: true, ids: copied}
|
||||
select {
|
||||
case c.outboundControl <- op:
|
||||
case <-c.outboundStop:
|
||||
default:
|
||||
c.metrics.OutboundDropped("ack_queue_full")
|
||||
}
|
||||
}
|
||||
|
||||
// OutgoingStateInfo 返回本连接出站消息的状态。返回值中 0 表示无出站侧意见,
|
||||
// 调用方可继续用入站 connState 兜底。
|
||||
func (c *Conn) OutgoingStateInfo(ctx context.Context, ids []int64) ([]byte, error) {
|
||||
if c.outbound == nil {
|
||||
return nil, ErrConnClosed
|
||||
}
|
||||
op := outboundOp{
|
||||
kind: outboundQueryState,
|
||||
control: true,
|
||||
ctx: ctx,
|
||||
ids: append([]int64(nil), ids...),
|
||||
done: make(chan outboundResult, 1),
|
||||
}
|
||||
if err := c.enqueueOutbound(ctx, op); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case res := <-op.done:
|
||||
return res.info, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.outboundStop:
|
||||
return nil, ErrConnClosed
|
||||
}
|
||||
}
|
||||
|
||||
// ResendMessages 重发仍在 outgoing queue 中的 server 消息,并返回对应状态。
|
||||
func (c *Conn) ResendMessages(ctx context.Context, ids []int64) ([]byte, error) {
|
||||
if c.outbound == nil {
|
||||
return nil, ErrConnClosed
|
||||
}
|
||||
op := outboundOp{
|
||||
kind: outboundResend,
|
||||
control: true,
|
||||
ctx: ctx,
|
||||
ids: append([]int64(nil), ids...),
|
||||
done: make(chan outboundResult, 1),
|
||||
}
|
||||
if err := c.enqueueOutbound(ctx, op); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case res := <-op.done:
|
||||
return res.info, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.outboundStop:
|
||||
return nil, ErrConnClosed
|
||||
}
|
||||
}
|
||||
|
||||
// ResendByRequest 在重复 RPC 请求到达时,按原 client msg_id 找到并重发已有 rpc_result。
|
||||
func (c *Conn) ResendByRequest(ctx context.Context, reqMsgID int64) (bool, error) {
|
||||
if c.outbound == nil {
|
||||
return false, ErrConnClosed
|
||||
}
|
||||
op := outboundOp{
|
||||
kind: outboundResendByRequest,
|
||||
control: true,
|
||||
ctx: ctx,
|
||||
reqMsgID: reqMsgID,
|
||||
done: make(chan outboundResult, 1),
|
||||
}
|
||||
if err := c.enqueueOutbound(ctx, op); err != nil {
|
||||
return false, err
|
||||
}
|
||||
select {
|
||||
case res := <-op.done:
|
||||
return res.resent, res.err
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
case <-c.outboundStop:
|
||||
return false, ErrConnClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) enqueueOutbound(ctx context.Context, op outboundOp) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
q := c.outbound
|
||||
if op.control {
|
||||
q = c.outboundControl
|
||||
}
|
||||
select {
|
||||
case q <- op:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.outboundStop:
|
||||
return ErrConnClosed
|
||||
default:
|
||||
}
|
||||
c.metrics.OutboundQueueWait(len(q), cap(q))
|
||||
select {
|
||||
case q <- op:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.outboundStop:
|
||||
return ErrConnClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) outboundLoop() {
|
||||
defer close(c.outboundDone)
|
||||
state := newOutboundState()
|
||||
for {
|
||||
select {
|
||||
case op := <-c.outboundControl:
|
||||
c.handleOutboundOp(state, op)
|
||||
continue
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.outboundStop:
|
||||
c.drainOutbound()
|
||||
return
|
||||
case op := <-c.outboundControl:
|
||||
c.handleOutboundOp(state, op)
|
||||
case op := <-c.outbound:
|
||||
c.handleOutboundOp(state, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) drainOutbound() {
|
||||
for {
|
||||
select {
|
||||
case op := <-c.outboundControl:
|
||||
op.finish(outboundResult{err: ErrConnClosed})
|
||||
case op := <-c.outbound:
|
||||
op.finish(outboundResult{err: ErrConnClosed})
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) handleOutboundOp(state *outboundState, op outboundOp) {
|
||||
switch op.kind {
|
||||
case outboundSend:
|
||||
c.handleOutboundSend(state, op)
|
||||
case outboundAck:
|
||||
state.ack(op.ids)
|
||||
case outboundQueryState:
|
||||
op.finish(outboundResult{info: state.stateInfo(op.ids)})
|
||||
case outboundResend:
|
||||
info, err := c.handleOutboundResend(state, op.ctx, op.ids)
|
||||
op.finish(outboundResult{info: info, err: err})
|
||||
case outboundResendByRequest:
|
||||
resent, err := c.handleOutboundResendByRequest(state, op.ctx, op.reqMsgID)
|
||||
op.finish(outboundResult{resent: resent, err: err})
|
||||
default:
|
||||
op.finish(outboundResult{err: fmt.Errorf("unknown outbound op %d", op.kind)})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) {
|
||||
frame, err := c.buildFrame(op.msgType, op.msg)
|
||||
if err == nil {
|
||||
err = c.writeFrame(op.ctx, frame)
|
||||
}
|
||||
if err == nil && frameNeedsAck(frame.typeID) {
|
||||
if dropped := state.add(frame); dropped > 0 {
|
||||
for i := 0; i < dropped; i++ {
|
||||
c.metrics.OutboundDropped("tracked_queue_overflow")
|
||||
}
|
||||
}
|
||||
}
|
||||
queueWait := time.Since(op.enqueuedAt)
|
||||
bytes := 0
|
||||
typeID := uint32(0)
|
||||
if frame != nil {
|
||||
bytes = len(frame.body)
|
||||
typeID = frame.typeID
|
||||
}
|
||||
c.metrics.OutboundSend(typeID, queueWait, bytes, err)
|
||||
op.finish(outboundResult{err: err})
|
||||
}
|
||||
|
||||
func (c *Conn) handleOutboundResend(state *outboundState, ctx context.Context, ids []int64) ([]byte, error) {
|
||||
info := make([]byte, len(ids))
|
||||
resent := 0
|
||||
for i, id := range ids {
|
||||
if state.isKnown(id) {
|
||||
info[i] = msgStateReceived
|
||||
}
|
||||
frame, ok := state.pending[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := c.writeFrame(ctx, frame); err != nil {
|
||||
c.metrics.OutboundResend(resent, err)
|
||||
return info, err
|
||||
}
|
||||
frame.sentAt = time.Now()
|
||||
frame.sends++
|
||||
resent++
|
||||
}
|
||||
c.metrics.OutboundResend(resent, nil)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (c *Conn) handleOutboundResendByRequest(state *outboundState, ctx context.Context, reqMsgID int64) (bool, error) {
|
||||
msgID, ok := state.byRequest[reqMsgID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
frame, ok := state.pending[msgID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if err := c.writeFrame(ctx, frame); err != nil {
|
||||
c.metrics.OutboundResend(0, err)
|
||||
return false, err
|
||||
}
|
||||
frame.sentAt = time.Now()
|
||||
frame.sends++
|
||||
c.metrics.OutboundResend(1, nil)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (op outboundOp) finish(res outboundResult) {
|
||||
if op.done == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case op.done <- res:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) buildFrame(t proto.MessageType, msg bin.Encoder) (*outboundFrame, error) {
|
||||
if msg == nil {
|
||||
return nil, errors.New("nil outbound message")
|
||||
}
|
||||
var body bin.Buffer
|
||||
if err := msg.Encode(&body); err != nil {
|
||||
return nil, fmt.Errorf("encode outbound: %w", err)
|
||||
}
|
||||
typeID, err := (&bin.Buffer{Buf: body.Raw()}).PeekID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peek outbound type id: %w", err)
|
||||
}
|
||||
content := frameNeedsAck(typeID)
|
||||
msgID := c.msgID.New(t)
|
||||
return &outboundFrame{
|
||||
msgID: msgID,
|
||||
seqNo: c.nextSeqNo(content),
|
||||
typeID: typeID,
|
||||
body: body.Copy(),
|
||||
reqMsgID: outboundRequestMsgID(msg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Conn) nextSeqNo(content bool) int32 {
|
||||
seqNo := c.sentContentMessages * 2
|
||||
if content {
|
||||
seqNo++
|
||||
c.sentContentMessages++
|
||||
}
|
||||
return seqNo
|
||||
}
|
||||
|
||||
func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var out bin.Buffer
|
||||
if err := c.cipher.Encrypt(c.key, crypto.EncryptedMessageData{
|
||||
Salt: c.salt,
|
||||
SessionID: c.sessionID,
|
||||
MessageID: frame.msgID,
|
||||
SeqNo: frame.seqNo,
|
||||
MessageDataLen: int32(len(frame.body)),
|
||||
MessageDataWithPadding: frame.body,
|
||||
}, &out); err != nil {
|
||||
return fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
|
||||
sendCtx := ctx
|
||||
cancel := func() {}
|
||||
if c.writeTimeout > 0 {
|
||||
sendCtx, cancel = context.WithTimeout(ctx, c.writeTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
writer := c.writer
|
||||
if writer == nil {
|
||||
writer = c.transport
|
||||
}
|
||||
if err := writer.Send(sendCtx, &out); err != nil {
|
||||
return fmt.Errorf("send: %w", err)
|
||||
}
|
||||
if frame.sentAt.IsZero() {
|
||||
frame.sentAt = time.Now()
|
||||
frame.sends = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func frameNeedsAck(typeID uint32) bool {
|
||||
switch typeID {
|
||||
case mt.MsgsAckTypeID,
|
||||
mt.BadMsgNotificationTypeID,
|
||||
mt.BadServerSaltTypeID,
|
||||
mt.MsgsStateInfoTypeID,
|
||||
mt.MsgsAllInfoTypeID,
|
||||
mt.MsgDetailedInfoTypeID,
|
||||
mt.MsgNewDetailedInfoTypeID,
|
||||
proto.MessageContainerTypeID:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func outboundRequestMsgID(msg bin.Encoder) int64 {
|
||||
switch v := msg.(type) {
|
||||
case *proto.Result:
|
||||
return v.RequestMessageID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *outboundState) add(frame *outboundFrame) int {
|
||||
s.pending[frame.msgID] = frame
|
||||
s.order = append(s.order, frame.msgID)
|
||||
s.totalBytes += len(frame.body)
|
||||
if frame.reqMsgID != 0 {
|
||||
s.byRequest[frame.reqMsgID] = frame.msgID
|
||||
}
|
||||
return s.shrinkPending()
|
||||
}
|
||||
|
||||
func (s *outboundState) ack(ids []int64) {
|
||||
for _, id := range ids {
|
||||
frame, ok := s.pending[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(s.pending, id)
|
||||
s.totalBytes -= len(frame.body)
|
||||
if frame.reqMsgID != 0 {
|
||||
delete(s.byRequest, frame.reqMsgID)
|
||||
}
|
||||
s.markAcked(id)
|
||||
}
|
||||
if len(s.order) > maxTrackedServerMsgIDs*2 {
|
||||
s.compactOrder()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *outboundState) stateInfo(ids []int64) []byte {
|
||||
info := make([]byte, len(ids))
|
||||
for i, id := range ids {
|
||||
if s.isKnown(id) {
|
||||
info[i] = msgStateReceived
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func (s *outboundState) isKnown(id int64) bool {
|
||||
if _, ok := s.pending[id]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := s.acked[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *outboundState) markAcked(id int64) {
|
||||
if _, ok := s.acked[id]; ok {
|
||||
return
|
||||
}
|
||||
s.acked[id] = struct{}{}
|
||||
s.ackOrder = append(s.ackOrder, id)
|
||||
for len(s.ackOrder) > maxTrackedAckedMsgIDs {
|
||||
oldest := s.ackOrder[0]
|
||||
s.ackOrder = s.ackOrder[1:]
|
||||
delete(s.acked, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *outboundState) shrinkPending() int {
|
||||
dropped := 0
|
||||
for (len(s.pending) > maxTrackedServerMsgIDs || s.totalBytes > maxTrackedServerBytes) && len(s.order) > 0 {
|
||||
oldest := s.order[0]
|
||||
s.order = s.order[1:]
|
||||
frame, ok := s.pending[oldest]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(s.pending, oldest)
|
||||
s.totalBytes -= len(frame.body)
|
||||
if frame.reqMsgID != 0 {
|
||||
delete(s.byRequest, frame.reqMsgID)
|
||||
}
|
||||
dropped++
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
func (s *outboundState) compactOrder() {
|
||||
filtered := s.order[:0]
|
||||
for _, id := range s.order {
|
||||
if _, ok := s.pending[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
s.order = filtered
|
||||
}
|
||||
130
internal/mtprotoedge/outbound_test.go
Normal file
130
internal/mtprotoedge/outbound_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func TestOutboundActorSerializesConcurrentSends(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, srv := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1})
|
||||
collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
srv.Conns().SetReceivesUpdates(auth.SessionID, true)
|
||||
|
||||
const sends = 64
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, sends)
|
||||
for i := 0; i < sends; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
errs <- srv.Conns().PushToSession(ctx, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var prevMsgID int64
|
||||
var prevSeqNo int32 = -1
|
||||
for i := 0; i < sends; i++ {
|
||||
data, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey)
|
||||
if id != tg.UpdatesTooLongTypeID {
|
||||
t.Fatalf("message %d type = %#x, want updatesTooLong", i, id)
|
||||
}
|
||||
if i > 0 && data.MessageID <= prevMsgID {
|
||||
t.Fatalf("message %d msg_id = %d after %d, want strictly increasing", i, data.MessageID, prevMsgID)
|
||||
}
|
||||
if data.SeqNo%2 != 1 {
|
||||
t.Fatalf("message %d seq_no = %d, want odd content-related seq_no", i, data.SeqNo)
|
||||
}
|
||||
if i > 0 && data.SeqNo <= prevSeqNo {
|
||||
t.Fatalf("message %d seq_no = %d after %d, want increasing", i, data.SeqNo, prevSeqNo)
|
||||
}
|
||||
prevMsgID = data.MessageID
|
||||
prevSeqNo = data.SeqNo
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundResendAndAckState(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, srv := startTestServer(t, Options{DC: dc})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1})
|
||||
collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID)
|
||||
srv.Conns().SetReceivesUpdates(auth.SessionID, true)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := srv.Conns().PushToSession(ctx, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
original, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey)
|
||||
if id != tg.UpdatesTooLongTypeID {
|
||||
t.Fatalf("pushed type = %#x, want updatesTooLong", id)
|
||||
}
|
||||
|
||||
resendReqID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, resendReqID, 3, &mt.MsgResendReq{MsgIDs: []int64{original.MessageID}})
|
||||
resent, resentType, _ := readServerMessage(t, conn, cipher, auth.AuthKey)
|
||||
if resentType != tg.UpdatesTooLongTypeID {
|
||||
t.Fatalf("resent type = %#x, want updatesTooLong", resentType)
|
||||
}
|
||||
if resent.MessageID != original.MessageID || resent.SeqNo != original.SeqNo {
|
||||
t.Fatalf("resent frame = (msg_id=%d seq=%d), want original (msg_id=%d seq=%d)",
|
||||
resent.MessageID, resent.SeqNo, original.MessageID, original.SeqNo)
|
||||
}
|
||||
_, stateType, stateBuf := readServerMessage(t, conn, cipher, auth.AuthKey)
|
||||
if stateType != mt.MsgsStateInfoTypeID {
|
||||
t.Fatalf("state type = %#x, want msgs_state_info", stateType)
|
||||
}
|
||||
assertStateInfo(t, stateBuf, resendReqID, []byte{msgStateReceived})
|
||||
_, ackType, _ := readServerMessage(t, conn, cipher, auth.AuthKey)
|
||||
if ackType != mt.MsgsAckTypeID {
|
||||
t.Fatalf("ack type = %#x, want msgs_ack", ackType)
|
||||
}
|
||||
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), 4, &mt.MsgsAck{MsgIDs: []int64{original.MessageID}})
|
||||
ackedResendReqID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, ackedResendReqID, 5, &mt.MsgResendReq{MsgIDs: []int64{original.MessageID}})
|
||||
_, ackedStateType, ackedStateBuf := readServerMessage(t, conn, cipher, auth.AuthKey)
|
||||
if ackedStateType != mt.MsgsStateInfoTypeID {
|
||||
t.Fatalf("after ack type = %#x, want msgs_state_info without resend", ackedStateType)
|
||||
}
|
||||
assertStateInfo(t, ackedStateBuf, ackedResendReqID, []byte{msgStateReceived})
|
||||
}
|
||||
|
||||
func assertStateInfo(t *testing.T, b *bin.Buffer, reqMsgID int64, want []byte) {
|
||||
t.Helper()
|
||||
var info mt.MsgsStateInfo
|
||||
if err := info.Decode(b); err != nil {
|
||||
t.Fatalf("decode msgs_state_info: %v", err)
|
||||
}
|
||||
if info.ReqMsgID != reqMsgID {
|
||||
t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID)
|
||||
}
|
||||
if string(info.Info) != string(want) {
|
||||
t.Fatalf("msgs_state_info.info = %v, want %v", []byte(info.Info), want)
|
||||
}
|
||||
}
|
||||
145
internal/mtprotoedge/rpc_test.go
Normal file
145
internal/mtprotoedge/rpc_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/rpc"
|
||||
)
|
||||
|
||||
// TestRPCGetConfig 验证 M3:握手后 client 加密 help.getConfig,
|
||||
// server 经 tg.ServerDispatcher 路由并回 rpc_result(含本地 DC),外加 new_session_created + ack。
|
||||
func TestRPCGetConfig(t *testing.T) {
|
||||
const (
|
||||
dc = 2
|
||||
advIP = "127.0.0.1"
|
||||
advPort = 12345
|
||||
)
|
||||
router := rpc.New(rpc.Config{DC: dc, IP: advIP, Port: advPort}, rpc.Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: router})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
reqMsgID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncrypted(t, conn, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{})
|
||||
|
||||
replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
|
||||
if _, ok := replies[mt.MsgsAckTypeID]; !ok {
|
||||
for id, b := range collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) {
|
||||
replies[id] = b
|
||||
}
|
||||
}
|
||||
mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created")
|
||||
mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack")
|
||||
resultBuf := mustHave(t, replies, proto.ResultTypeID, "rpc_result")
|
||||
|
||||
var res proto.Result
|
||||
if err := res.Decode(resultBuf); err != nil {
|
||||
t.Fatalf("decode rpc_result: %v", err)
|
||||
}
|
||||
if res.RequestMessageID != reqMsgID {
|
||||
t.Fatalf("rpc_result req_msg_id = %d, want %d", res.RequestMessageID, reqMsgID)
|
||||
}
|
||||
|
||||
var cfg tg.Config
|
||||
if err := cfg.Decode(&bin.Buffer{Buf: res.Result}); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if cfg.ThisDC != dc {
|
||||
t.Fatalf("config.ThisDC = %d, want %d", cfg.ThisDC, dc)
|
||||
}
|
||||
if len(cfg.DCOptions) != 1 {
|
||||
t.Fatalf("config.DCOptions count = %d, want 1", len(cfg.DCOptions))
|
||||
}
|
||||
if got := cfg.DCOptions[0]; got.ID != dc || got.IPAddress != advIP || got.Port != advPort {
|
||||
t.Fatalf("DCOption = %+v, want id=%d ip=%s port=%d", got, dc, advIP, advPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundRPCQueueFullReturnsFloodWait(t *testing.T) {
|
||||
const dc = 2
|
||||
handler := &blockingRPC{
|
||||
started: make(chan struct{}, 1),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
addr, pub, _ := startTestServer(t, Options{
|
||||
DC: dc,
|
||||
RPC: handler,
|
||||
RPCMaxInflight: 1,
|
||||
RPCQueueSize: 1,
|
||||
RPCTimeout: 5 * time.Second,
|
||||
})
|
||||
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
clientMsgID := proto.NewMessageIDGen(time.Now)
|
||||
firstReqID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, firstReqID, 1, &tg.HelpGetConfigRequest{})
|
||||
select {
|
||||
case <-handler.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for first rpc to start")
|
||||
}
|
||||
|
||||
secondReqID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, secondReqID, 3, &tg.HelpGetConfigRequest{})
|
||||
thirdReqID := clientMsgID.New(proto.MessageFromClient)
|
||||
sendEncryptedWithSeq(t, conn, cipher, auth, thirdReqID, 5, &tg.HelpGetConfigRequest{})
|
||||
|
||||
result := readRPCResultForRequest(t, conn, cipher, auth.AuthKey, thirdReqID)
|
||||
var rpcErr mt.RPCError
|
||||
if err := rpcErr.Decode(&bin.Buffer{Buf: result.Result}); err != nil {
|
||||
t.Fatalf("decode rpc_error: %v", err)
|
||||
}
|
||||
if rpcErr.ErrorCode != 420 || rpcErr.ErrorMessage != "FLOOD_WAIT_1" {
|
||||
t.Fatalf("rpc_error = %d %q, want 420 FLOOD_WAIT_1", rpcErr.ErrorCode, rpcErr.ErrorMessage)
|
||||
}
|
||||
close(handler.release)
|
||||
}
|
||||
|
||||
type blockingRPC struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (h *blockingRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) {
|
||||
select {
|
||||
case h.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-h.release:
|
||||
return &tg.Config{ThisDC: 2}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func readRPCResultForRequest(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey, reqMsgID int64) proto.Result {
|
||||
t.Helper()
|
||||
for i := 0; i < 12; i++ {
|
||||
_, id, plain := readServerMessage(t, conn, cipher, key)
|
||||
if id != proto.ResultTypeID {
|
||||
continue
|
||||
}
|
||||
var result proto.Result
|
||||
if err := result.Decode(plain); err != nil {
|
||||
t.Fatalf("decode rpc_result: %v", err)
|
||||
}
|
||||
if result.RequestMessageID == reqMsgID {
|
||||
return result
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing rpc_result for req_msg_id %d", reqMsgID)
|
||||
return proto.Result{}
|
||||
}
|
||||
63
internal/mtprotoedge/rsakey.go
Normal file
63
internal/mtprotoedge/rsakey.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// rsaKeyBits 是 server RSA 私钥位数。MTProto 要求 2048-bit。
|
||||
const rsaKeyBits = 2048
|
||||
|
||||
// LoadOrGenerateRSAKey 从 path 加载 PEM 编码的 server RSA 私钥;
|
||||
// 不存在则生成 2048-bit 新密钥并持久化(含父目录)。
|
||||
//
|
||||
// server RSA 私钥用于 MTProto 密钥交换;其公钥 fingerprint 需 patch 进 TDesktop
|
||||
// (记录于 docs/tdesktop-patch-notes.md)。
|
||||
func LoadOrGenerateRSAKey(path string) (*rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
key, perr := parseRSAKeyPEM(data)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("parse %q: %w", path, perr)
|
||||
}
|
||||
return key, nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// 继续生成。
|
||||
default:
|
||||
return nil, fmt.Errorf("read %q: %w", path, err)
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create key dir %q: %w", dir, err)
|
||||
}
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write key %q: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func parseRSAKeyPEM(data []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block found")
|
||||
}
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
}
|
||||
364
internal/mtprotoedge/server.go
Normal file
364
internal/mtprotoedge/server.go
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tmap"
|
||||
"github.com/gotd/td/transport"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// RPCHandler 把解密后的 RPC 请求体路由到响应。由 internal/rpc 实现。
|
||||
//
|
||||
// b 是明文 RPC 请求(已剥离 MTProto 外壳);返回的 bin.Encoder 会被包成 rpc_result。
|
||||
// 返回 *tgerr.Error 时连接层将其转为 rpc_error 回发;其他 error 视为连接级故障。
|
||||
type RPCHandler interface {
|
||||
Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error)
|
||||
}
|
||||
|
||||
// Options 配置 Server。
|
||||
type Options struct {
|
||||
// Logger 日志器。默认 zap.NewNop()。
|
||||
Logger *zap.Logger
|
||||
// Codec 传输 codec 构造器。nil 表示自动探测(intermediate/abridged/full)。
|
||||
Codec func() transport.Codec
|
||||
// ObfuscatedTCP 先按 MTProto TCP obfuscation 解包,再自动探测 codec。
|
||||
// Telegram Desktop 的 tcpo_only endpoint 会走这个 64 字节前缀流程。
|
||||
ObfuscatedTCP bool
|
||||
// ReadTimeout 单次读取超时。默认 5m。
|
||||
ReadTimeout time.Duration
|
||||
// HandshakeIdleTimeout 是连接「建立 session 前」(握手 + 首个加密消息之前)的读超时,
|
||||
// 比 ReadTimeout 短,用于快速回收握手后静默的半开 / 异常连接。默认 60s。
|
||||
HandshakeIdleTimeout time.Duration
|
||||
// WriteTimeout 单次写入超时。默认 30s。
|
||||
WriteTimeout time.Duration
|
||||
// RPCMaxInflight 是单连接同时处理的 RPC 上限。默认 32。
|
||||
RPCMaxInflight int
|
||||
// RPCQueueSize 是单连接等待处理的 RPC 队列长度。默认 256。
|
||||
RPCQueueSize int
|
||||
// RPCTimeout 是单个 RPC 在连接层的最大处理时长。默认 30s。
|
||||
RPCTimeout time.Duration
|
||||
|
||||
// DC 是本 server 的 DC ID。默认 2。
|
||||
DC int
|
||||
// RSAKey 是 server RSA 私钥,用于密钥交换。nil 时无法完成握手。
|
||||
RSAKey *rsa.PrivateKey
|
||||
// AuthKeys 持久化 auth key。默认内存实现。
|
||||
AuthKeys store.AuthKeyStore
|
||||
// Sessions 记录在线 MTProto session(持久化数据)。默认内存实现。
|
||||
Sessions store.SessionStore
|
||||
// ActiveSessions 管理活跃连接。默认新建;传入时可让 RPC 层共享同一注册表。
|
||||
ActiveSessions *SessionManager
|
||||
// RPC 是 typed RPC 路由。nil 时加密 RPC 被丢弃并记录。
|
||||
RPC RPCHandler
|
||||
// Metrics 接收连接层指标。默认 NopMetrics。
|
||||
Metrics Metrics
|
||||
// Clock 用于消息 ID 与时间戳。默认 clock.System。
|
||||
Clock clock.Clock
|
||||
// Rand 随机源。默认 crypto.DefaultRand()。
|
||||
Rand io.Reader
|
||||
}
|
||||
|
||||
func (o *Options) setDefaults() {
|
||||
if o.Logger == nil {
|
||||
o.Logger = zap.NewNop()
|
||||
}
|
||||
if o.ReadTimeout == 0 {
|
||||
o.ReadTimeout = 5 * time.Minute
|
||||
}
|
||||
if o.HandshakeIdleTimeout == 0 {
|
||||
o.HandshakeIdleTimeout = 60 * time.Second
|
||||
}
|
||||
if o.WriteTimeout == 0 {
|
||||
o.WriteTimeout = 30 * time.Second
|
||||
}
|
||||
if o.RPCMaxInflight <= 0 {
|
||||
o.RPCMaxInflight = 32
|
||||
}
|
||||
if o.RPCQueueSize <= 0 {
|
||||
o.RPCQueueSize = 256
|
||||
}
|
||||
if o.RPCTimeout == 0 {
|
||||
o.RPCTimeout = 30 * time.Second
|
||||
}
|
||||
if o.DC == 0 {
|
||||
o.DC = 2
|
||||
}
|
||||
if o.AuthKeys == nil {
|
||||
o.AuthKeys = memory.NewAuthKeyStore()
|
||||
}
|
||||
if o.Sessions == nil {
|
||||
o.Sessions = memory.NewSessionStore()
|
||||
}
|
||||
if o.Metrics == nil {
|
||||
o.Metrics = NopMetrics{}
|
||||
}
|
||||
if o.Clock == nil {
|
||||
o.Clock = clock.System
|
||||
}
|
||||
if o.Rand == nil {
|
||||
o.Rand = crypto.DefaultRand()
|
||||
}
|
||||
}
|
||||
|
||||
// Server 是 MTProto 连接层(mtprotoedge)。
|
||||
//
|
||||
// 职责见 doc.go。它把原始 TCP 字节流转换为「已解密、已识别 session 的 RPC 请求」:
|
||||
// 接受连接、协商 codec、完成密钥交换、解密并分发加密消息到 RPC 路由,处理服务消息,
|
||||
// 并把活跃连接注册到 SessionManager 以支持主动推送(updates 等)。不含业务逻辑。
|
||||
type Server struct {
|
||||
log *zap.Logger
|
||||
codec func() transport.Codec
|
||||
obfuscated bool
|
||||
readTimeout time.Duration
|
||||
handshakeTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
rpcInflight int
|
||||
rpcQueueSize int
|
||||
rpcTimeout time.Duration
|
||||
|
||||
dc int
|
||||
key exchange.PrivateKey
|
||||
authKeys store.AuthKeyStore
|
||||
sessions store.SessionStore
|
||||
conns *SessionManager
|
||||
rpc RPCHandler
|
||||
metrics Metrics
|
||||
cipher crypto.Cipher
|
||||
clock clock.Clock
|
||||
rand io.Reader
|
||||
types *tmap.Map
|
||||
|
||||
// sessionUID 是本进程 server session 唯一标识,写入 new_session_created。
|
||||
sessionUID int64
|
||||
|
||||
// onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。
|
||||
onFrame func(n int)
|
||||
}
|
||||
|
||||
// New 创建 Server。
|
||||
func New(opts Options) *Server {
|
||||
opts.setDefaults()
|
||||
conns := opts.ActiveSessions
|
||||
if conns == nil {
|
||||
conns = NewSessionManager(opts.Logger.Named("sessions"))
|
||||
}
|
||||
return &Server{
|
||||
log: opts.Logger,
|
||||
codec: opts.Codec,
|
||||
obfuscated: opts.ObfuscatedTCP,
|
||||
readTimeout: opts.ReadTimeout,
|
||||
handshakeTimeout: opts.HandshakeIdleTimeout,
|
||||
writeTimeout: opts.WriteTimeout,
|
||||
rpcInflight: opts.RPCMaxInflight,
|
||||
rpcQueueSize: opts.RPCQueueSize,
|
||||
rpcTimeout: opts.RPCTimeout,
|
||||
dc: opts.DC,
|
||||
key: exchange.PrivateKey{RSA: opts.RSAKey},
|
||||
authKeys: opts.AuthKeys,
|
||||
sessions: opts.Sessions,
|
||||
conns: conns,
|
||||
rpc: opts.RPC,
|
||||
metrics: opts.Metrics,
|
||||
cipher: crypto.NewServerCipher(opts.Rand),
|
||||
clock: opts.Clock,
|
||||
rand: opts.Rand,
|
||||
types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()),
|
||||
sessionUID: opts.Clock.Now().UnixNano(),
|
||||
}
|
||||
}
|
||||
|
||||
// Conns 返回活跃连接注册表,供业务层主动推送(updates 等)。
|
||||
func (s *Server) Conns() *SessionManager {
|
||||
return s.conns
|
||||
}
|
||||
|
||||
// newConn 基于一次解密结果创建一个可发送的连接对象。
|
||||
func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt int64) *Conn {
|
||||
c := &Conn{
|
||||
transport: tc,
|
||||
writer: tc,
|
||||
cipher: s.cipher,
|
||||
msgID: proto.NewMessageIDGen(s.clock.Now),
|
||||
writeTimeout: s.writeTimeout,
|
||||
metrics: s.metrics,
|
||||
authKeyID: key.ID,
|
||||
sessionID: sessionID,
|
||||
salt: salt,
|
||||
key: key,
|
||||
}
|
||||
c.startOutbound()
|
||||
c.startInboundRPCScheduler(s.rpcInflight, s.rpcQueueSize, s.rpcTimeout)
|
||||
return c
|
||||
}
|
||||
|
||||
// Serve 在 ln 上运行 MTProto 连接循环,直到 ctx 取消或发生不可恢复错误。
|
||||
// ctx 取消时优雅退出:关闭 listener 并等待在途连接处理结束。
|
||||
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
transportListener := ln
|
||||
if s.obfuscated {
|
||||
transportListener = transport.ObfuscatedListener(ln)
|
||||
}
|
||||
l := transport.ListenCodec(s.codec, transportListener)
|
||||
s.log.Info("Serving", zap.String("addr", ln.Addr().String()), zap.Int("dc", s.dc), zap.Bool("obfuscated_tcp", s.obfuscated))
|
||||
defer s.log.Info("Stopped")
|
||||
|
||||
// ctx 取消时关闭 listener,解除 Accept 阻塞。
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = l.Close()
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
if s.obfuscated && isClientDisconnect(err) {
|
||||
s.log.Debug("Ignoring failed obfuscated accept", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("accept: %w", err)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := s.serveConn(ctx, conn); err != nil && !isClientDisconnect(err) {
|
||||
s.log.Info("Connection closed with error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// serveConn 处理单个传输连接:读帧并按 auth_key_id 分流。
|
||||
//
|
||||
// - auth_key_id == 0:未加密的密钥交换起始消息,执行握手并落地 auth key。
|
||||
// - auth_key_id 已注册:加密消息,解密、注册连接并分发到 RPC 路由。
|
||||
// - auth_key_id 未注册:回 AuthKeyNotFound,促使客户端重新握手。
|
||||
//
|
||||
// 连接建立 session 后注册到 SessionManager,结束时注销。
|
||||
func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error) {
|
||||
s.metrics.ConnOpened()
|
||||
s.log.Debug("Connection accepted")
|
||||
|
||||
var current *Conn
|
||||
defer func() {
|
||||
if current != nil {
|
||||
s.conns.Unregister(current)
|
||||
current.Close()
|
||||
}
|
||||
s.metrics.ConnClosed()
|
||||
s.log.Debug("Connection closed", zap.Error(err))
|
||||
}()
|
||||
|
||||
// ctx 取消或处理结束时关闭连接,解除 Recv 阻塞。
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
cs := newConnState()
|
||||
var b bin.Buffer
|
||||
var replay *bin.Buffer
|
||||
for {
|
||||
if replay != nil {
|
||||
b.ResetTo(replay.Copy())
|
||||
replay = nil
|
||||
} else {
|
||||
// 建立 session 前(current==nil,握手 + 首个加密消息之前)用较短的 handshakeTimeout
|
||||
// 快速回收静默的半开 / 异常连接;建立 session 后用 readTimeout(客户端有 ping 心跳)。
|
||||
timeout := s.readTimeout
|
||||
if current == nil {
|
||||
timeout = s.handshakeTimeout
|
||||
}
|
||||
if err := s.recv(ctx, conn, &b, timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.onFrame != nil {
|
||||
s.onFrame(b.Len())
|
||||
}
|
||||
}
|
||||
|
||||
authKeyID, err := peekAuthKeyID(&b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("peek auth key id: %w", err)
|
||||
}
|
||||
|
||||
if authKeyID == emptyAuthKeyID {
|
||||
next, err := s.handleExchange(ctx, conn, &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replay = next
|
||||
continue
|
||||
}
|
||||
|
||||
data, found, err := s.authKeys.Get(ctx, authKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup auth key: %w", err)
|
||||
}
|
||||
if !found {
|
||||
if err := s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current, err = s.handleEncrypted(ctx, conn, cs, current, data, &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recv(ctx context.Context, conn transport.Conn, b *bin.Buffer, timeout time.Duration) error {
|
||||
b.Reset()
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
return conn.Recv(ctx, b)
|
||||
}
|
||||
|
||||
// isClientDisconnect 判断错误是否为正常的客户端断开/服务关闭,不应作为异常记录。
|
||||
func isClientDisconnect(err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, io.EOF),
|
||||
errors.Is(err, net.ErrClosed),
|
||||
errors.Is(err, context.Canceled),
|
||||
errors.Is(err, context.DeadlineExceeded):
|
||||
return true
|
||||
}
|
||||
var nerr *net.OpError
|
||||
if errors.As(err, &nerr) && (nerr.Op == "read" || nerr.Op == "write") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
165
internal/mtprotoedge/server_test.go
Normal file
165
internal/mtprotoedge/server_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/mtproxy"
|
||||
"github.com/gotd/td/mtproxy/obfuscator"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/transport"
|
||||
)
|
||||
|
||||
// TestServerAcceptAndCodec 验证 M0:
|
||||
// server 能接受连接、自动协商 codec、读到客户端帧,并在 ctx 取消时优雅退出。
|
||||
func TestServerAcceptAndCodec(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
frames := make(chan int, 1)
|
||||
srv := New(Options{Logger: zaptest.NewLogger(t)})
|
||||
srv.onFrame = func(n int) {
|
||||
select {
|
||||
case frames <- n:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
// 客户端:TCP 拨号 + intermediate 协议握手 + 发送一帧。
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn, err := transport.Intermediate.Handshake(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("handshake: %v", err)
|
||||
}
|
||||
|
||||
// payload 必须 ≠ 4 字节:codec 把恰好 4 字节的帧当作 transport 协议错误码(checkProtocolError)。
|
||||
// 真实 MTProto 帧远大于 4 字节,这里发 8 字节模拟一个普通帧。
|
||||
var b bin.Buffer
|
||||
b.PutInt32(0x12345678)
|
||||
b.PutInt32(0x0badf00d)
|
||||
sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer sc()
|
||||
if err := conn.Send(sendCtx, &b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case n := <-frames:
|
||||
if n <= 0 {
|
||||
t.Fatalf("received empty frame, len = %d", n)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server did not receive frame in time")
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
// 验证优雅退出。
|
||||
cancel()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server did not stop after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerAcceptObfuscatedAbridged 验证 TDesktop tcpo_only 连接形态:
|
||||
// 先做 MTProto TCP obfuscation,再在解密后的流上使用 abridged codec。
|
||||
func TestServerAcceptObfuscatedAbridged(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
frames := make(chan int, 1)
|
||||
srv := New(Options{Logger: zaptest.NewLogger(t), ObfuscatedTCP: true})
|
||||
srv.onFrame = func(n int) {
|
||||
select {
|
||||
case frames <- n:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
bad, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("bad dial: %v", err)
|
||||
}
|
||||
_ = bad.Close()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
t.Fatalf("server stopped after bad obfuscated accept: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
obfs := obfuscator.Obfuscated2(rand.Reader, raw)
|
||||
if err := obfs.Handshake((codec.Abridged{}).ObfuscatedTag(), 2, mtproxy.Secret{}); err != nil {
|
||||
t.Fatalf("obfuscated handshake: %v", err)
|
||||
}
|
||||
conn, err := transport.NewProtocol(func() transport.Codec {
|
||||
return transport.Abridged.CodecNoHeader()
|
||||
}).Handshake(obfs)
|
||||
if err != nil {
|
||||
t.Fatalf("transport handshake: %v", err)
|
||||
}
|
||||
|
||||
var b bin.Buffer
|
||||
b.PutInt32(0x12345678)
|
||||
b.PutInt32(0x0badf00d)
|
||||
sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer sc()
|
||||
if err := conn.Send(sendCtx, &b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case n := <-frames:
|
||||
if n <= 0 {
|
||||
t.Fatalf("received empty frame, len = %d", n)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server did not receive frame in time")
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server did not stop after ctx cancel")
|
||||
}
|
||||
}
|
||||
978
internal/mtprotoedge/session_manager.go
Normal file
978
internal/mtprotoedge/session_manager.go
Normal file
|
|
@ -0,0 +1,978 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/proto"
|
||||
)
|
||||
|
||||
// ErrSessionNotFound 表示目标 session 当前无活跃连接。
|
||||
var ErrSessionNotFound = errors.New("session not found")
|
||||
|
||||
// ErrSessionAmbiguous 表示仅用 session_id 无法唯一定位连接。
|
||||
var ErrSessionAmbiguous = errors.New("session id is shared by multiple auth keys")
|
||||
|
||||
const (
|
||||
maxPendingPushesPerSession = 32
|
||||
// pendingPushMaxAge:session 注册后迟迟不调 updates.getState(receivesUpdates 恒 false)时,
|
||||
// 其暂存的主动推送最长保留时长。超过即丢整批并不再囤——正常 TDesktop 登录后秒级就会
|
||||
// getState 建立同步基线;长期不 ready 多为异常/对抗连接。丢弃不丢消息:getDifference 以
|
||||
// user_update_events durable log 兜底补齐。
|
||||
pendingPushMaxAge = 60 * time.Second
|
||||
)
|
||||
|
||||
type queuedPush struct {
|
||||
t proto.MessageType
|
||||
msg bin.Encoder
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type sessionKey struct {
|
||||
authKeyID [8]byte
|
||||
sessionID int64
|
||||
}
|
||||
|
||||
// SessionLifecycleObserver receives active connection lifecycle events.
|
||||
type SessionLifecycleObserver interface {
|
||||
SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool)
|
||||
}
|
||||
|
||||
// SessionManager 是活跃连接注册表,支持按 session / auth-key / user 查找并主动 push。
|
||||
//
|
||||
// 它管理运行态的在线连接,与持久化的 store.SessionStore 互补:后者记录 session 数据,
|
||||
// 前者持有可发送的活跃连接。所有方法并发安全。
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
bySession map[sessionKey]*Conn
|
||||
bySessionID map[int64]map[[8]byte]*Conn // sessionID → raw authKeyID → Conn,用于兼容旧 API 的唯一性检查
|
||||
byAuthKey map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn
|
||||
byUser map[int64]map[sessionKey]*Conn
|
||||
byChannel map[int64]map[sessionKey]int64 // channelID → session → userID,用于频道 active-viewer 临时推送
|
||||
bySessionChannels map[sessionKey]map[int64]struct{}
|
||||
byMemberChannel map[int64]map[sessionKey]int64 // channelID → session → userID,用于已上线成员持久 update 推送
|
||||
bySessionMembers map[sessionKey]map[int64]struct{}
|
||||
pending map[sessionKey][]queuedPush // updates-ready 前暂存的主动推送
|
||||
|
||||
lifecycle SessionLifecycleObserver
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewSessionManager 创建空的连接注册表。
|
||||
func NewSessionManager(log *zap.Logger) *SessionManager {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &SessionManager{
|
||||
bySession: make(map[sessionKey]*Conn),
|
||||
bySessionID: make(map[int64]map[[8]byte]*Conn),
|
||||
byAuthKey: make(map[[8]byte]map[int64]*Conn),
|
||||
byUser: make(map[int64]map[sessionKey]*Conn),
|
||||
byChannel: make(map[int64]map[sessionKey]int64),
|
||||
bySessionChannels: make(map[sessionKey]map[int64]struct{}),
|
||||
byMemberChannel: make(map[int64]map[sessionKey]int64),
|
||||
bySessionMembers: make(map[sessionKey]map[int64]struct{}),
|
||||
pending: make(map[sessionKey][]queuedPush),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLifecycleObserver installs a best-effort active session lifecycle observer.
|
||||
func (m *SessionManager) SetLifecycleObserver(observer SessionLifecycleObserver) {
|
||||
m.mu.Lock()
|
||||
m.lifecycle = observer
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Register 注册一个活跃连接。若同 raw auth_key_id + session_id 已存在(重连),旧连接被替换并移除索引。
|
||||
func (m *SessionManager) Register(c *Conn) {
|
||||
m.mu.Lock()
|
||||
|
||||
key := connSessionKey(c)
|
||||
var replaced *Conn
|
||||
if old, ok := m.bySession[key]; ok && old != c {
|
||||
replaced = old
|
||||
m.removeLocked(old, false)
|
||||
}
|
||||
m.bySession[key] = c
|
||||
addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c)
|
||||
addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c)
|
||||
if uid := c.userID.Load(); uid != 0 {
|
||||
c.userIDResolved.Store(true)
|
||||
addUserIndex(m.byUser, uid, key, c)
|
||||
}
|
||||
m.log.Debug("Session registered",
|
||||
zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
|
||||
zap.Int64("session_id", c.sessionID),
|
||||
zap.Int("online", len(m.bySession)),
|
||||
)
|
||||
m.mu.Unlock()
|
||||
|
||||
if replaced != nil {
|
||||
replaced.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。
|
||||
func (m *SessionManager) Unregister(c *Conn) {
|
||||
m.mu.Lock()
|
||||
var (
|
||||
observer SessionLifecycleObserver
|
||||
offlineUser int64
|
||||
lastForUser bool
|
||||
)
|
||||
if cur, ok := m.bySession[connSessionKey(c)]; ok && cur == c {
|
||||
offlineUser = m.removeLocked(c, true)
|
||||
if offlineUser != 0 {
|
||||
lastForUser = len(m.byUser[offlineUser]) == 0
|
||||
observer = m.lifecycle
|
||||
}
|
||||
m.log.Debug("Session unregistered",
|
||||
zap.String("auth_key_id", sessionKeyLog(c.authKeyID)),
|
||||
zap.Int64("session_id", c.sessionID),
|
||||
zap.Int("online", len(m.bySession)),
|
||||
)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if observer != nil && offlineUser != 0 {
|
||||
observer.SessionOffline(c.authKeyID, c.sessionID, offlineUser, lastForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// DestroySession 移除指定 session 的运行态索引,供 MTProto destroy_session 使用。
|
||||
func (m *SessionManager) DestroySession(sessionID int64) bool {
|
||||
m.mu.Lock()
|
||||
c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
if ambiguous || !ok {
|
||||
if !ambiguous {
|
||||
m.dropPendingBySessionLocked(sessionID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
offlineUser := m.removeLocked(c, true)
|
||||
lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
|
||||
observer := m.lifecycle
|
||||
m.log.Debug("Session destroyed",
|
||||
zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
|
||||
zap.Int64("session_id", sessionID),
|
||||
zap.Int("online", len(m.bySession)),
|
||||
)
|
||||
m.mu.Unlock()
|
||||
c.Close()
|
||||
if observer != nil && offlineUser != 0 {
|
||||
observer.SessionOffline(key.authKeyID, sessionID, offlineUser, lastForUser)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DestroySessionForAuthKey 精确移除某个 raw auth_key_id 下的 session。
|
||||
func (m *SessionManager) DestroySessionForAuthKey(authKeyID [8]byte, sessionID int64) bool {
|
||||
m.mu.Lock()
|
||||
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
|
||||
c, ok := m.bySession[key]
|
||||
if !ok {
|
||||
delete(m.pending, key)
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
offlineUser := m.removeLocked(c, true)
|
||||
lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0
|
||||
observer := m.lifecycle
|
||||
m.log.Debug("Session destroyed",
|
||||
zap.String("auth_key_id", sessionKeyLog(authKeyID)),
|
||||
zap.Int64("session_id", sessionID),
|
||||
zap.Int("online", len(m.bySession)),
|
||||
)
|
||||
m.mu.Unlock()
|
||||
c.Close()
|
||||
if observer != nil && offlineUser != 0 {
|
||||
observer.SessionOffline(authKeyID, sessionID, offlineUser, lastForUser)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BindUser 缓存 session 的授权用户。userID=0 表示当前 auth_key 已确认未登录。
|
||||
// 登录后绑定非 0 userID,使其可经 PushToUser 收到推送。
|
||||
func (m *SessionManager) BindUser(sessionID, userID int64) {
|
||||
m.mu.Lock()
|
||||
c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
if ambiguous || !ok {
|
||||
if ambiguous {
|
||||
m.log.Warn("Skip BindUser for ambiguous session_id", zap.Int64("session_id", sessionID))
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.bindUserLocked(c, key, userID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// BindUserForAuthKey 缓存指定 raw auth_key_id + session_id 的授权用户。
|
||||
func (m *SessionManager) BindUserForAuthKey(authKeyID [8]byte, sessionID, userID int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
|
||||
c, ok := m.bySession[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.bindUserLocked(c, key, userID)
|
||||
}
|
||||
|
||||
func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) {
|
||||
if old := c.userID.Swap(userID); old != 0 {
|
||||
removeUserIndex(m.byUser, old, key)
|
||||
if old != userID {
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
}
|
||||
}
|
||||
c.userIDResolved.Store(true)
|
||||
if userID != 0 {
|
||||
addUserIndex(m.byUser, userID, key, c)
|
||||
} else {
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
}
|
||||
}
|
||||
|
||||
// UserID 返回 session 当前缓存的登录用户 id。未绑定或离线时 ok=false。
|
||||
func (m *SessionManager) UserID(sessionID int64) (int64, bool) {
|
||||
m.mu.RLock()
|
||||
c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
m.mu.RUnlock()
|
||||
if ambiguous || !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID := c.userID.Load()
|
||||
if userID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// UserIDForAuthKey 返回指定 raw auth_key_id + session_id 当前缓存的登录用户 id。
|
||||
func (m *SessionManager) UserIDForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) {
|
||||
m.mu.RLock()
|
||||
c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID := c.userID.Load()
|
||||
if userID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// UserIDResolved 返回 session 的 user_id 授权状态是否已经查过。
|
||||
// resolved=true 且 userID=0 表示该 session 当前未登录。
|
||||
func (m *SessionManager) UserIDResolved(sessionID int64) (int64, bool) {
|
||||
m.mu.RLock()
|
||||
c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
m.mu.RUnlock()
|
||||
if ambiguous || !ok {
|
||||
return 0, false
|
||||
}
|
||||
return c.UserIDResolved()
|
||||
}
|
||||
|
||||
// UserIDResolvedForAuthKey 返回指定 raw auth_key_id + session_id 的 user_id 缓存状态。
|
||||
func (m *SessionManager) UserIDResolvedForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) {
|
||||
m.mu.RLock()
|
||||
c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return c.UserIDResolved()
|
||||
}
|
||||
|
||||
// BindAuthKey 缓存业务视角 auth_key_id(temp auth_key 解析后的 perm auth_key)。
|
||||
func (m *SessionManager) BindAuthKey(sessionID int64, authKeyID [8]byte) {
|
||||
m.mu.Lock()
|
||||
c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
if ambiguous || !ok {
|
||||
if ambiguous {
|
||||
m.log.Warn("Skip BindAuthKey for ambiguous session_id", zap.Int64("session_id", sessionID))
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.bindAuthKeyLocked(c, key, authKeyID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// BindAuthKeyForSession 缓存指定 raw auth_key_id + session_id 的业务 auth_key_id。
|
||||
func (m *SessionManager) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
c, ok := m.bySession[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.bindAuthKeyLocked(c, key, authKeyID)
|
||||
}
|
||||
|
||||
func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8]byte) {
|
||||
oldAuthKeyID, resolved := c.BusinessAuthKeyID()
|
||||
changed := !resolved || oldAuthKeyID != authKeyID
|
||||
oldUserID := c.userID.Load()
|
||||
c.SetBusinessAuthKeyID(authKeyID)
|
||||
if changed {
|
||||
if oldUserID != 0 {
|
||||
removeUserIndex(m.byUser, oldUserID, key)
|
||||
}
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
c.userID.Store(0)
|
||||
c.userIDResolved.Store(false)
|
||||
}
|
||||
}
|
||||
|
||||
// AuthKeyID 返回 session 缓存的业务视角 auth_key_id。
|
||||
// ok=false 表示该连接尚未完成 temp→perm 解析。
|
||||
func (m *SessionManager) AuthKeyID(sessionID int64) ([8]byte, bool) {
|
||||
m.mu.RLock()
|
||||
c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
m.mu.RUnlock()
|
||||
if ambiguous || !ok {
|
||||
return [8]byte{}, false
|
||||
}
|
||||
return c.BusinessAuthKeyID()
|
||||
}
|
||||
|
||||
// AuthKeyIDForSession 返回指定 raw auth_key_id + session_id 缓存的业务 auth_key_id。
|
||||
func (m *SessionManager) AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool) {
|
||||
m.mu.RLock()
|
||||
c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return [8]byte{}, false
|
||||
}
|
||||
return c.BusinessAuthKeyID()
|
||||
}
|
||||
|
||||
// UnbindAuthKey 清理某业务 auth_key 下所有活跃连接的登录用户缓存。
|
||||
func (m *SessionManager) UnbindAuthKey(authKeyID [8]byte) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
count := 0
|
||||
for key, c := range m.bySession {
|
||||
if !connUsesBusinessAuthKey(c, authKeyID) {
|
||||
continue
|
||||
}
|
||||
if old := c.userID.Swap(0); old != 0 {
|
||||
removeUserIndex(m.byUser, old, key)
|
||||
}
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
c.userIDResolved.Store(true)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// SetReceivesUpdates 标记 session 是否已完成 updates 同步入口。
|
||||
//
|
||||
// TDesktop 登录后会先调用 updates.getState/getDifference 建立本地同步基线。
|
||||
// 在此之前收到的主动 updates 先暂存,待 session 可接收后再异步下发。
|
||||
func (m *SessionManager) SetReceivesUpdates(sessionID int64, receives bool) {
|
||||
m.mu.Lock()
|
||||
c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
if ambiguous || !ok {
|
||||
if ambiguous {
|
||||
m.log.Warn("Skip SetReceivesUpdates for ambiguous session_id", zap.Int64("session_id", sessionID))
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.receivesUpdates.Store(receives)
|
||||
if !receives {
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
}
|
||||
pending := m.takePendingLocked(key, receives)
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(pending) > 0 {
|
||||
go m.flushPending(key, pending)
|
||||
}
|
||||
}
|
||||
|
||||
// SetReceivesUpdatesForAuthKey 标记指定 raw auth_key_id + session_id 是否接收主动 updates。
|
||||
func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64, receives bool) {
|
||||
m.mu.Lock()
|
||||
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
|
||||
c, ok := m.bySession[key]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.receivesUpdates.Store(receives)
|
||||
if !receives {
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
}
|
||||
pending := m.takePendingLocked(key, receives)
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(pending) > 0 {
|
||||
go m.flushPending(key, pending)
|
||||
}
|
||||
}
|
||||
|
||||
// PushToSession 向指定 session 推送一条消息。
|
||||
func (m *SessionManager) PushToSession(ctx context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error {
|
||||
m.mu.Lock()
|
||||
c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID)
|
||||
if ambiguous {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionAmbiguous
|
||||
}
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
if !c.receivesUpdates.Load() {
|
||||
m.queueLocked(key, t, msg)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return c.Send(ctx, t, msg)
|
||||
}
|
||||
|
||||
// PushToSessionForAuthKey 向指定 raw auth_key_id + session_id 推送一条消息。
|
||||
func (m *SessionManager) PushToSessionForAuthKey(ctx context.Context, authKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error {
|
||||
m.mu.Lock()
|
||||
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
|
||||
c, ok := m.bySession[key]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
if !c.receivesUpdates.Load() {
|
||||
m.queueLocked(key, t, msg)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return c.Send(ctx, t, msg)
|
||||
}
|
||||
|
||||
// PushToUser 向某 user 所有活跃连接推送,返回已发送或已暂存的连接数。
|
||||
// 发送在释放锁后进行,避免持锁阻塞于网络 IO。
|
||||
func (m *SessionManager) PushToUser(ctx context.Context, userID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
|
||||
return m.PushToUserExceptAuthKeySession(ctx, userID, [8]byte{}, 0, t, msg)
|
||||
}
|
||||
|
||||
// PushToUserExceptSession 向某 user 所有活跃连接推送,但跳过指定 session。
|
||||
// 未完成 updates 同步入口的 session 会先暂存,等 SetReceivesUpdates(true) 后再发。
|
||||
func (m *SessionManager) PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
|
||||
return m.pushToUser(ctx, userID, nil, excludeSessionID, t, msg)
|
||||
}
|
||||
|
||||
// PushToUserExceptAuthKeySession 向某 user 所有活跃连接推送,跳过指定业务 auth_key + session。
|
||||
func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
|
||||
return m.pushToUser(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg)
|
||||
}
|
||||
|
||||
func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
|
||||
return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error {
|
||||
return c.Send(ctx, t, msg)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *SessionManager) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) {
|
||||
return m.pushToUserBestEffort(ctx, userID, nil, excludeSessionID, t, msg, timeout)
|
||||
}
|
||||
|
||||
func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) {
|
||||
return m.pushToUserBestEffort(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg, timeout)
|
||||
}
|
||||
|
||||
func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) {
|
||||
return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error {
|
||||
return c.SendBestEffort(ctx, t, msg, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, send func(*Conn) error) (int, error) {
|
||||
m.mu.Lock()
|
||||
conns := make([]*Conn, 0, len(m.byUser[userID]))
|
||||
queued := 0
|
||||
for key, c := range m.byUser[userID] {
|
||||
if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) {
|
||||
continue
|
||||
}
|
||||
if !c.receivesUpdates.Load() {
|
||||
m.queueLocked(key, t, msg)
|
||||
queued++
|
||||
continue
|
||||
}
|
||||
conns = append(conns, c)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
sent := 0
|
||||
for _, c := range conns {
|
||||
if err := send(c); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
return sent + queued, firstErr
|
||||
}
|
||||
|
||||
// Online 返回当前活跃连接数。
|
||||
func (m *SessionManager) Online() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.bySession)
|
||||
}
|
||||
|
||||
// OnlineUserIDs returns a bounded snapshot of users that currently have active
|
||||
// sessions. Callers still need to verify business visibility before pushing.
|
||||
func (m *SessionManager) OnlineUserIDs(limit int) []int64 {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.byUser) == 0 {
|
||||
return nil
|
||||
}
|
||||
capHint := len(m.byUser)
|
||||
if limit > 0 && capHint > limit {
|
||||
capHint = limit
|
||||
}
|
||||
ids := make([]int64, 0, capHint)
|
||||
for userID, conns := range m.byUser {
|
||||
if userID == 0 || len(conns) == 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, userID)
|
||||
if limit > 0 && len(ids) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// IsUserOnline returns whether userID has at least one active connection.
|
||||
func (m *SessionManager) IsUserOnline(userID int64) bool {
|
||||
if userID == 0 {
|
||||
return false
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.byUser[userID]) > 0
|
||||
}
|
||||
|
||||
// OnlineUserIDsForCandidates filters an explicit candidate set against the
|
||||
// active user index. It avoids exporting or sorting the whole online map.
|
||||
func (m *SessionManager) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
|
||||
if len(candidateUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]int64, 0, minInt(len(candidateUserIDs), positiveLimitOrLen(limit, len(candidateUserIDs))))
|
||||
seen := make(map[int64]struct{}, len(candidateUserIDs))
|
||||
for _, userID := range candidateUserIDs {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
if len(m.byUser[userID]) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TrackChannelInterest replaces the channel viewer set for one live session.
|
||||
// Realtime transient fan-out uses this as the current active-viewer candidate
|
||||
// set; durable channel updates use the broader membership index instead.
|
||||
func (m *SessionManager) TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.bySession[key]
|
||||
if !ok || c.userID.Load() != userID {
|
||||
return
|
||||
}
|
||||
m.clearChannelInterestsLocked(key)
|
||||
if len(channelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
m.trackChannelIndexLocked(m.byChannel, m.bySessionChannels, key, userID, channelIDs)
|
||||
}
|
||||
|
||||
// ClearChannelInterest removes the active-viewer channel set for one live
|
||||
// session while leaving its joined-channel membership index intact.
|
||||
func (m *SessionManager) ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.bySession[key]
|
||||
if !ok || c.userID.Load() != userID {
|
||||
return
|
||||
}
|
||||
m.clearChannelInterestsLocked(key)
|
||||
}
|
||||
|
||||
// OnlineChannelUserIDs returns users with active sessions that have recently
|
||||
// proven current interest in channelID. The result is intentionally unsorted and bounded.
|
||||
func (m *SessionManager) OnlineChannelUserIDs(channelID int64, limit int) []int64 {
|
||||
return m.onlineChannelUsers(m.byChannel, channelID, limit)
|
||||
}
|
||||
|
||||
// SetSessionChannelMemberships replaces the joined-channel index for one
|
||||
// updates-ready session. This index is broader than TrackChannelInterest and is
|
||||
// used for durable channel updates such as new/edit/delete message.
|
||||
func (m *SessionManager) SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) {
|
||||
key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.bySession[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
if userID == 0 || c.userID.Load() != userID {
|
||||
return
|
||||
}
|
||||
m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, channelIDs)
|
||||
}
|
||||
|
||||
// AddUserChannelMembership adds channelID to every live session for userID.
|
||||
// It is called after successful join/invite approval paths.
|
||||
func (m *SessionManager) AddUserChannelMembership(userID, channelID int64) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for key, c := range m.byUser[userID] {
|
||||
if c == nil || c.userID.Load() != userID {
|
||||
continue
|
||||
}
|
||||
m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, []int64{channelID})
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveUserChannelMembership removes channelID from every live session for userID.
|
||||
// It is called after leave/kick/ban/delete paths.
|
||||
func (m *SessionManager) RemoveUserChannelMembership(userID, channelID int64) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for key := range m.byUser[userID] {
|
||||
m.removeChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
// OnlineChannelMemberUserIDs returns users with active sessions that are indexed
|
||||
// as joined members of channelID. The result is intentionally unsorted; callers
|
||||
// still verify business membership before pushing.
|
||||
func (m *SessionManager) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 {
|
||||
return m.onlineChannelUsers(m.byMemberChannel, channelID, limit)
|
||||
}
|
||||
|
||||
func (m *SessionManager) onlineChannelUsers(index map[int64]map[sessionKey]int64, channelID int64, limit int) []int64 {
|
||||
if channelID == 0 {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
sessions := index[channelID]
|
||||
if len(sessions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, positiveLimitOrLen(limit, len(sessions)))
|
||||
seen := make(map[int64]struct{}, len(sessions))
|
||||
for key, userID := range sessions {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := m.bySession[key]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
out = append(out, userID)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 {
|
||||
key := connSessionKey(c)
|
||||
delete(m.bySession, key)
|
||||
removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID)
|
||||
removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID)
|
||||
uid := c.userID.Load()
|
||||
if uid != 0 {
|
||||
removeUserIndex(m.byUser, uid, key)
|
||||
}
|
||||
m.clearChannelInterestsLocked(key)
|
||||
m.clearChannelMembershipsLocked(key)
|
||||
if dropPending {
|
||||
delete(m.pending, key)
|
||||
}
|
||||
return uid
|
||||
}
|
||||
|
||||
func (m *SessionManager) clearChannelInterestsLocked(key sessionKey) {
|
||||
m.clearChannelIndexLocked(m.byChannel, m.bySessionChannels, key)
|
||||
}
|
||||
|
||||
func (m *SessionManager) clearChannelMembershipsLocked(key sessionKey) {
|
||||
m.clearChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key)
|
||||
}
|
||||
|
||||
func (m *SessionManager) trackChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, userID int64, channelIDs []int64) {
|
||||
channels := reverse[key]
|
||||
if channels == nil {
|
||||
channels = make(map[int64]struct{}, len(channelIDs))
|
||||
reverse[key] = channels
|
||||
}
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
channels[channelID] = struct{}{}
|
||||
sessions := index[channelID]
|
||||
if sessions == nil {
|
||||
sessions = make(map[sessionKey]int64)
|
||||
index[channelID] = sessions
|
||||
}
|
||||
sessions[key] = userID
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) clearChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey) {
|
||||
channels := reverse[key]
|
||||
if len(channels) == 0 {
|
||||
delete(reverse, key)
|
||||
return
|
||||
}
|
||||
for channelID := range channels {
|
||||
sessions := index[channelID]
|
||||
delete(sessions, key)
|
||||
if len(sessions) == 0 {
|
||||
delete(index, channelID)
|
||||
}
|
||||
}
|
||||
delete(reverse, key)
|
||||
}
|
||||
|
||||
func (m *SessionManager) removeChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, channelID int64) {
|
||||
channels := reverse[key]
|
||||
delete(channels, channelID)
|
||||
if len(channels) == 0 {
|
||||
delete(reverse, key)
|
||||
}
|
||||
sessions := index[channelID]
|
||||
delete(sessions, key)
|
||||
if len(sessions) == 0 {
|
||||
delete(index, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
func positiveLimitOrLen(limit, length int) int {
|
||||
if limit > 0 && limit < length {
|
||||
return limit
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (m *SessionManager) takePendingLocked(key sessionKey, ready bool) []queuedPush {
|
||||
if !ready || len(m.pending[key]) == 0 {
|
||||
return nil
|
||||
}
|
||||
pending := append([]queuedPush(nil), m.pending[key]...)
|
||||
delete(m.pending, key)
|
||||
return pending
|
||||
}
|
||||
|
||||
func (m *SessionManager) queueLocked(key sessionKey, t proto.MessageType, msg bin.Encoder) {
|
||||
q := m.pending[key]
|
||||
// 过期保护:最早一条暂存已超过 pendingPushMaxAge(session 迟迟未 ready)时,丢整批并
|
||||
// 不再囤这条,记 trace。避免「登录后从不 getState」的连接长期占用 pending 内存。
|
||||
if len(q) > 0 && time.Since(q[0].at) > pendingPushMaxAge {
|
||||
m.log.Debug("Drop stale pending pushes (session not ready in time)",
|
||||
zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
|
||||
zap.Int64("session_id", key.sessionID),
|
||||
zap.Int("dropped", len(q)),
|
||||
)
|
||||
delete(m.pending, key)
|
||||
return
|
||||
}
|
||||
push := queuedPush{t: t, msg: msg, at: time.Now()}
|
||||
if len(q) >= maxPendingPushesPerSession {
|
||||
copy(q, q[1:])
|
||||
q[len(q)-1] = push
|
||||
m.pending[key] = q
|
||||
return
|
||||
}
|
||||
m.pending[key] = append(q, push)
|
||||
}
|
||||
|
||||
func (m *SessionManager) flushPending(key sessionKey, pending []queuedPush) {
|
||||
for _, item := range pending {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err := m.PushToSessionForAuthKey(ctx, key.authKeyID, key.sessionID, item.t, item.msg)
|
||||
cancel()
|
||||
if err != nil {
|
||||
m.log.Debug("Flush pending push failed",
|
||||
zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
|
||||
zap.Int64("session_id", key.sessionID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) uniqueSessionLocked(sessionID int64) (*Conn, sessionKey, bool, bool) {
|
||||
set := m.bySessionID[sessionID]
|
||||
if len(set) == 0 {
|
||||
return nil, sessionKey{}, false, false
|
||||
}
|
||||
if len(set) > 1 {
|
||||
return nil, sessionKey{}, false, true
|
||||
}
|
||||
for authKeyID, c := range set {
|
||||
return c, sessionKey{authKeyID: authKeyID, sessionID: sessionID}, true, false
|
||||
}
|
||||
return nil, sessionKey{}, false, false
|
||||
}
|
||||
|
||||
func (m *SessionManager) dropPendingBySessionLocked(sessionID int64) {
|
||||
for key := range m.pending {
|
||||
if key.sessionID == sessionID {
|
||||
delete(m.pending, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64, c *Conn) {
|
||||
set := idx[key]
|
||||
if set == nil {
|
||||
set = make(map[int64]*Conn)
|
||||
idx[key] = set
|
||||
}
|
||||
set[sessionID] = c
|
||||
}
|
||||
|
||||
func removeConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64) {
|
||||
if set := idx[key]; set != nil {
|
||||
delete(set, sessionID)
|
||||
if len(set) == 0 {
|
||||
delete(idx, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte, c *Conn) {
|
||||
set := idx[sessionID]
|
||||
if set == nil {
|
||||
set = make(map[[8]byte]*Conn)
|
||||
idx[sessionID] = set
|
||||
}
|
||||
set[authKeyID] = c
|
||||
}
|
||||
|
||||
func removeSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte) {
|
||||
if set := idx[sessionID]; set != nil {
|
||||
delete(set, authKeyID)
|
||||
if len(set) == 0 {
|
||||
delete(idx, sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey, c *Conn) {
|
||||
set := idx[userID]
|
||||
if set == nil {
|
||||
set = make(map[sessionKey]*Conn)
|
||||
idx[userID] = set
|
||||
}
|
||||
set[key] = c
|
||||
}
|
||||
|
||||
func removeUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey) {
|
||||
if set := idx[userID]; set != nil {
|
||||
delete(set, key)
|
||||
if len(set) == 0 {
|
||||
delete(idx, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connSessionKey(c *Conn) sessionKey {
|
||||
return sessionKey{authKeyID: c.authKeyID, sessionID: c.sessionID}
|
||||
}
|
||||
|
||||
func connUsesBusinessAuthKey(c *Conn, authKeyID [8]byte) bool {
|
||||
id, resolved := c.BusinessAuthKeyID()
|
||||
if resolved {
|
||||
return id == authKeyID
|
||||
}
|
||||
return c.authKeyID == authKeyID
|
||||
}
|
||||
|
||||
func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID int64) bool {
|
||||
if excludeSessionID == 0 {
|
||||
return false
|
||||
}
|
||||
if c.sessionID != excludeSessionID {
|
||||
return false
|
||||
}
|
||||
if excludeAuthKeyID == nil || *excludeAuthKeyID == ([8]byte{}) {
|
||||
return true
|
||||
}
|
||||
return connUsesBusinessAuthKey(c, *excludeAuthKeyID)
|
||||
}
|
||||
|
||||
func sessionKeyLog(id [8]byte) string {
|
||||
return fmt.Sprintf("%x", id)
|
||||
}
|
||||
215
internal/mtprotoedge/session_manager_bench_test.go
Normal file
215
internal/mtprotoedge/session_manager_bench_test.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
// 连接层 fan-out / churn 压测:聚焦 SessionManager 的锁争用,不走真实 socket / 加密。
|
||||
//
|
||||
// 构造的 Conn 故意不 startOutbound:pushToUser 持锁快照 byUser 后,锁外对每个 conn 调 c.Send,
|
||||
// 此时 c.outbound==nil 立即返回 ErrConnClosed(见 outbound.go),因此测量集中在「持锁段 + 分发开销」,
|
||||
// 即分片要消除的全局锁热点。每条连接仅结构体内存(无 1024 容量的 outbound channel、无 goroutine),
|
||||
// 故可注册到 20 万规模。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManager -benchmem -cpu 1,4,8
|
||||
// go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManagerPushConcurrent -mutexprofile mu.out
|
||||
// TELESRV_LOAD_CONNS=200000 go test ./internal/mtprotoedge/ -run TestSessionManagerFanoutThroughput -v -timeout 300s
|
||||
|
||||
func benchConn(sessionID int64, authKeyID [8]byte, userID int64) *Conn {
|
||||
c := &Conn{sessionID: sessionID, authKeyID: authKeyID}
|
||||
if userID != 0 {
|
||||
c.userID.Store(userID)
|
||||
c.userIDResolved.Store(true)
|
||||
}
|
||||
c.receivesUpdates.Store(true) // 走 fanout 的「收集 conns→锁外 Send」分支,而非 pending 暂存
|
||||
return c
|
||||
}
|
||||
|
||||
func authKeyIDFromInt(v uint64) [8]byte {
|
||||
var id [8]byte
|
||||
for i := 0; i < 8; i++ {
|
||||
id[i] = byte(v >> (8 * i))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedSessions 注册 conns 个连接,每个 user 绑定 connsPerUser 个连接(模拟多设备)。
|
||||
// 返回注册的 userID 列表(去重、有序范围 [1, userCount])。
|
||||
func seedSessions(sm *SessionManager, conns, connsPerUser int) (userCount int) {
|
||||
if connsPerUser < 1 {
|
||||
connsPerUser = 1
|
||||
}
|
||||
for i := 0; i < conns; i++ {
|
||||
userID := int64(i/connsPerUser) + 1
|
||||
sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID))
|
||||
}
|
||||
return (conns + connsPerUser - 1) / connsPerUser
|
||||
}
|
||||
|
||||
// BenchmarkSessionManagerPushConcurrent 模拟 20 万在线下的真实热点:大量 goroutine 并发对
|
||||
// 不同 user pushToUser,全部抢同一把全局锁。-mutexprofile 会把 SessionManager.mu 顶上来。
|
||||
func BenchmarkSessionManagerPushConcurrent(b *testing.B) {
|
||||
const conns = 200_000
|
||||
const connsPerUser = 2
|
||||
sm := NewSessionManager(zap.NewNop())
|
||||
userCount := seedSessions(sm, conns, connsPerUser)
|
||||
msg := &tg.UpdatesTooLong{}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
var n uint64
|
||||
for pb.Next() {
|
||||
n++
|
||||
userID := int64(n%uint64(userCount)) + 1
|
||||
_, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkSessionManagerRegisterChurn 测连接建立/断开的锁成本:并发 Register+Unregister。
|
||||
// 20 万在线意味着持续的 connect/disconnect churn,每次都抢全局写锁。
|
||||
func BenchmarkSessionManagerRegisterChurn(b *testing.B) {
|
||||
sm := NewSessionManager(zap.NewNop())
|
||||
var seq atomic.Uint64
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
id := seq.Add(1)
|
||||
c := benchConn(int64(id), authKeyIDFromInt(id), int64(id))
|
||||
sm.Register(c)
|
||||
sm.Unregister(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkSessionManagerPushFanoutWidth 测单次 push 的 fanout 广度成本:一个 user 绑定很多连接,
|
||||
// 单次 PushToUser 要持锁遍历全部。真实私聊 user 设备数少(2-4),此为上界参考。
|
||||
func BenchmarkSessionManagerPushFanoutWidth(b *testing.B) {
|
||||
for _, width := range []int{1, 4, 16, 64} {
|
||||
b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) {
|
||||
sm := NewSessionManager(zap.NewNop())
|
||||
const userID = 1
|
||||
for i := 0; i < width; i++ {
|
||||
sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID))
|
||||
}
|
||||
msg := &tg.UpdatesTooLong{}
|
||||
ctx := context.Background()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionManagerFanoutThroughput 是数据驱动吞吐测:注册 N 连接后,P 个 goroutine 持续并发
|
||||
// push,测全局锁下的实际 push 吞吐与 p99。默认小规模冒烟;设 TELESRV_LOAD_CONNS 放大到 20 万。
|
||||
func TestSessionManagerFanoutThroughput(t *testing.T) {
|
||||
conns := envIntDefault("TELESRV_LOAD_CONNS", 20_000)
|
||||
connsPerUser := envIntDefault("TELESRV_LOAD_CONNS_PER_USER", 2)
|
||||
workers := envIntDefault("TELESRV_LOAD_PUSH_WORKERS", 0) // 0 → GOMAXPROCS
|
||||
duration := time.Duration(envIntDefault("TELESRV_LOAD_SECONDS", 3)) * time.Second
|
||||
if workers <= 0 {
|
||||
workers = runtime.GOMAXPROCS(0)
|
||||
}
|
||||
|
||||
sm := NewSessionManager(zap.NewNop())
|
||||
t0 := time.Now()
|
||||
userCount := seedSessions(sm, conns, connsPerUser)
|
||||
seedWall := time.Since(t0)
|
||||
if got := sm.Online(); got != conns {
|
||||
t.Fatalf("online = %d, want %d", got, conns)
|
||||
}
|
||||
|
||||
msg := &tg.UpdatesTooLong{}
|
||||
ctx := context.Background()
|
||||
var ops atomic.Int64
|
||||
perWorkerLat := make([][]time.Duration, workers)
|
||||
|
||||
deadline := time.Now().Add(duration)
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
lat := make([]time.Duration, 0, 1<<16)
|
||||
var n uint64
|
||||
for time.Now().Before(deadline) {
|
||||
// 批量 256 次再查一次时钟,降低 time.Now 占比。
|
||||
for j := 0; j < 256; j++ {
|
||||
n++
|
||||
userID := int64(n%uint64(userCount)) + 1
|
||||
s := time.Now()
|
||||
_, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg)
|
||||
lat = append(lat, time.Since(s))
|
||||
}
|
||||
ops.Add(256)
|
||||
}
|
||||
perWorkerLat[w] = lat
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
wall := time.Since(start)
|
||||
|
||||
all := make([]time.Duration, 0, ops.Load())
|
||||
for _, l := range perWorkerLat {
|
||||
all = append(all, l...)
|
||||
}
|
||||
sortDurations(all)
|
||||
total := ops.Load()
|
||||
thr := float64(total) / wall.Seconds()
|
||||
|
||||
t.Logf("==== session_manager fan-out throughput ====")
|
||||
t.Logf("config: conns=%d connsPerUser=%d users=%d pushWorkers=%d dur=%s seed=%s",
|
||||
conns, connsPerUser, userCount, workers, duration, seedWall.Round(time.Millisecond))
|
||||
t.Logf("push: %d ops in %s -> %.0f push/s", total, wall.Round(time.Millisecond), thr)
|
||||
t.Logf("push.lat p50=%s p90=%s p99=%s max=%s",
|
||||
pct(all, 50), pct(all, 90), pct(all, 99), pct(all, 100))
|
||||
t.Logf("=============================================")
|
||||
}
|
||||
|
||||
func pct(sorted []time.Duration, p int) time.Duration {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := (p*len(sorted))/100 - 1
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sorted) {
|
||||
idx = len(sorted) - 1
|
||||
}
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
func sortDurations(d []time.Duration) {
|
||||
sort.Slice(d, func(i, j int) bool { return d[i] < d[j] })
|
||||
}
|
||||
|
||||
func envIntDefault(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
296
internal/mtprotoedge/session_manager_test.go
Normal file
296
internal/mtprotoedge/session_manager_test.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
// TestSessionManagerRegistry 验证注册表的注册/注销/查找语义(不涉及网络发送)。
|
||||
func TestSessionManagerRegistry(t *testing.T) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(t))
|
||||
c := &Conn{sessionID: 42, authKeyID: [8]byte{1, 2, 3}}
|
||||
c.receivesUpdates.Store(true)
|
||||
|
||||
sm.Register(c)
|
||||
if got := sm.Online(); got != 1 {
|
||||
t.Fatalf("online = %d, want 1", got)
|
||||
}
|
||||
sm.BindAuthKey(42, [8]byte{1, 2, 3})
|
||||
sm.BindUser(42, 100)
|
||||
if userID, ok := sm.UserID(42); !ok || userID != 100 {
|
||||
t.Fatalf("cached user = %d ok %v, want 100/true", userID, ok)
|
||||
}
|
||||
sm.BindAuthKey(42, [8]byte{9})
|
||||
if userID, ok := sm.UserID(42); ok || userID != 0 {
|
||||
t.Fatalf("cached user after auth key switch = %d ok %v, want 0/false", userID, ok)
|
||||
}
|
||||
if userID, resolved := sm.UserIDResolved(42); resolved || userID != 0 {
|
||||
t.Fatalf("resolved user after auth key switch = %d resolved %v, want unresolved", userID, resolved)
|
||||
}
|
||||
sm.BindUser(42, 0)
|
||||
if userID, resolved := sm.UserIDResolved(42); !resolved || userID != 0 {
|
||||
t.Fatalf("negative user cache = %d resolved %v, want 0/true", userID, resolved)
|
||||
}
|
||||
|
||||
sm.Unregister(c)
|
||||
if got := sm.Online(); got != 0 {
|
||||
t.Fatalf("online after unregister = %d, want 0", got)
|
||||
}
|
||||
|
||||
err := sm.PushToSession(context.Background(), 42, proto.MessageFromServer, &tg.UpdatesTooLong{})
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("push to missing session err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerScopesSameSessionIDByAuthKey(t *testing.T) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(t))
|
||||
raw1 := [8]byte{1}
|
||||
raw2 := [8]byte{2}
|
||||
perm1 := [8]byte{9}
|
||||
c1 := &Conn{sessionID: 42, authKeyID: raw1}
|
||||
c2 := &Conn{sessionID: 42, authKeyID: raw2}
|
||||
|
||||
sm.Register(c1)
|
||||
sm.Register(c2)
|
||||
if got := sm.Online(); got != 2 {
|
||||
t.Fatalf("online = %d, want 2", got)
|
||||
}
|
||||
|
||||
sm.BindAuthKeyForSession(raw1, 42, perm1)
|
||||
sm.BindUserForAuthKey(raw1, 42, 100)
|
||||
sm.BindUserForAuthKey(raw2, 42, 200)
|
||||
|
||||
if userID, ok := sm.UserIDForAuthKey(raw1, 42); !ok || userID != 100 {
|
||||
t.Fatalf("scoped user raw1 = %d ok %v, want 100/true", userID, ok)
|
||||
}
|
||||
if userID, ok := sm.UserIDForAuthKey(raw2, 42); !ok || userID != 200 {
|
||||
t.Fatalf("scoped user raw2 = %d ok %v, want 200/true", userID, ok)
|
||||
}
|
||||
if _, ok := sm.UserID(42); ok {
|
||||
t.Fatal("legacy UserID unexpectedly resolved ambiguous session_id")
|
||||
}
|
||||
if err := sm.PushToSession(context.Background(), 42, proto.MessageFromServer, &tg.UpdatesTooLong{}); !errors.Is(err, ErrSessionAmbiguous) {
|
||||
t.Fatalf("ambiguous push err = %v, want ErrSessionAmbiguous", err)
|
||||
}
|
||||
|
||||
sm.BindUserForAuthKey(raw1, 42, 300)
|
||||
sm.BindUserForAuthKey(raw2, 42, 300)
|
||||
sent, err := sm.PushToUserExceptAuthKeySession(context.Background(), 300, perm1, 42, proto.MessageFromServer, &tg.UpdatesTooLong{})
|
||||
if err != nil {
|
||||
t.Fatalf("push except scoped session: %v", err)
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("pushed to %d sessions, want 1", sent)
|
||||
}
|
||||
if _, ok := sm.pending[sessionKey{authKeyID: raw1, sessionID: 42}]; ok {
|
||||
t.Fatal("excluded session received pending push")
|
||||
}
|
||||
if got := len(sm.pending[sessionKey{authKeyID: raw2, sessionID: 42}]); got != 1 {
|
||||
t.Fatalf("raw2 pending pushes = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerChannelInterestIndex(t *testing.T) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(t))
|
||||
raw := [8]byte{1, 2, 3}
|
||||
c := &Conn{sessionID: 42, authKeyID: raw}
|
||||
sm.Register(c)
|
||||
sm.BindUserForAuthKey(raw, 42, 100)
|
||||
|
||||
sm.TrackChannelInterest(raw, 42, 100, []int64{10, 10, 20})
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 100 {
|
||||
t.Fatalf("channel 10 online users = %v, want [100]", got)
|
||||
}
|
||||
sm.TrackChannelInterest(raw, 42, 100, []int64{20})
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel 10 after viewer switch = %v, want empty", got)
|
||||
}
|
||||
if got := sm.OnlineChannelUserIDs(20, 10); len(got) != 1 || got[0] != 100 {
|
||||
t.Fatalf("channel 20 after viewer switch = %v, want [100]", got)
|
||||
}
|
||||
sm.TrackChannelInterest(raw, 42, 100, []int64{10})
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel 10 online members before membership sync = %v, want empty", got)
|
||||
}
|
||||
sm.SetSessionChannelMemberships(raw, 42, 100, []int64{10, 30})
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 100 {
|
||||
t.Fatalf("channel 10 online members = %v, want [100]", got)
|
||||
}
|
||||
if got := sm.OnlineChannelUserIDs(30, 10); len(got) != 0 {
|
||||
t.Fatalf("channel 30 viewers = %v, want empty", got)
|
||||
}
|
||||
if got := sm.OnlineUserIDsForCandidates([]int64{0, 200, 100, 100}, 10); len(got) != 1 || got[0] != 100 {
|
||||
t.Fatalf("candidate online users = %v, want [100]", got)
|
||||
}
|
||||
|
||||
sm.BindUserForAuthKey(raw, 42, 200)
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel interest after user switch = %v, want empty", got)
|
||||
}
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel membership after user switch = %v, want empty", got)
|
||||
}
|
||||
sm.TrackChannelInterest(raw, 42, 200, []int64{10})
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 200 {
|
||||
t.Fatalf("channel 10 after re-track = %v, want [200]", got)
|
||||
}
|
||||
sm.AddUserChannelMembership(200, 10)
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 200 {
|
||||
t.Fatalf("channel 10 membership after add = %v, want [200]", got)
|
||||
}
|
||||
sm.RemoveUserChannelMembership(200, 10)
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel membership after remove = %v, want empty", got)
|
||||
}
|
||||
sm.ClearChannelInterest(raw, 42, 200)
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel interest after explicit clear = %v, want empty", got)
|
||||
}
|
||||
|
||||
sm.Unregister(c)
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel interest after unregister = %v, want empty", got)
|
||||
}
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("channel membership after unregister = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerClearsChannelIndexesOnAuthAndReadinessChanges(t *testing.T) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(t))
|
||||
raw := [8]byte{1, 2, 3}
|
||||
business := [8]byte{8}
|
||||
c := &Conn{sessionID: 42, authKeyID: raw}
|
||||
sm.Register(c)
|
||||
sm.BindAuthKeyForSession(raw, 42, business)
|
||||
sm.BindUserForAuthKey(raw, 42, 100)
|
||||
|
||||
track := func() {
|
||||
sm.TrackChannelInterest(raw, 42, 100, []int64{10})
|
||||
sm.SetSessionChannelMemberships(raw, 42, 100, []int64{10})
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 100 {
|
||||
t.Fatalf("channel viewers before cleanup = %v, want [100]", got)
|
||||
}
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 100 {
|
||||
t.Fatalf("channel members before cleanup = %v, want [100]", got)
|
||||
}
|
||||
}
|
||||
assertCleared := func(label string) {
|
||||
if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("%s viewers = %v, want empty", label, got)
|
||||
}
|
||||
if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 {
|
||||
t.Fatalf("%s members = %v, want empty", label, got)
|
||||
}
|
||||
}
|
||||
|
||||
track()
|
||||
sm.SetReceivesUpdatesForAuthKey(raw, 42, false)
|
||||
assertCleared("after receivesUpdates=false")
|
||||
|
||||
track()
|
||||
sm.BindAuthKeyForSession(raw, 42, [8]byte{9})
|
||||
assertCleared("after business auth key change")
|
||||
|
||||
sm.BindAuthKeyForSession(raw, 42, business)
|
||||
sm.BindUserForAuthKey(raw, 42, 100)
|
||||
track()
|
||||
if n := sm.UnbindAuthKey(business); n != 1 {
|
||||
t.Fatalf("UnbindAuthKey count = %d, want 1", n)
|
||||
}
|
||||
assertCleared("after unbind auth key")
|
||||
}
|
||||
|
||||
// TestSessionManagerPush 验证主动推送端到端:两个 client 连接握手并建立 session 后,
|
||||
// server 经 PushToSession / PushToUser 主动向其推送,client 收到。
|
||||
func TestSessionManagerPush(t *testing.T) {
|
||||
const dc = 2
|
||||
addr, pub, srv := startTestServer(t, Options{DC: dc})
|
||||
|
||||
conn1, auth1, cipher1 := dialHandshake(t, addr, dc, pub)
|
||||
conn2, auth2, cipher2 := dialHandshake(t, addr, dc, pub)
|
||||
|
||||
// 各发一个 ping 建立 session,触发注册(并清掉 new_session_created/pong/ack)。
|
||||
msgGen := proto.NewMessageIDGen(time.Now)
|
||||
sendEncrypted(t, conn1, cipher1, auth1, msgGen.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1})
|
||||
collectReplies(t, conn1, cipher1, auth1.AuthKey, mt.PongTypeID)
|
||||
sendEncrypted(t, conn2, cipher2, auth2, msgGen.New(proto.MessageFromClient), &mt.PingRequest{PingID: 2})
|
||||
collectReplies(t, conn2, cipher2, auth2.AuthKey, mt.PongTypeID)
|
||||
|
||||
if got := srv.Conns().Online(); got != 2 {
|
||||
t.Fatalf("online = %d, want 2", got)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1) PushToSession:session2 尚未进入 updates 同步入口时先暂存,ready 后下发。
|
||||
if err := srv.Conns().PushToSession(ctx, auth2.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil {
|
||||
t.Fatalf("push to session: %v", err)
|
||||
}
|
||||
srv.Conns().SetReceivesUpdates(auth2.SessionID, true)
|
||||
r2 := collectReplies(t, conn2, cipher2, auth2.AuthKey, tg.UpdatesTooLongTypeID)
|
||||
mustHave(t, r2, tg.UpdatesTooLongTypeID, "pushed updates on conn2")
|
||||
|
||||
// 2) BindUser + PushToUser:按 user 维度推送给 conn1。
|
||||
srv.Conns().BindUser(auth1.SessionID, 100)
|
||||
srv.Conns().SetReceivesUpdates(auth1.SessionID, true)
|
||||
sent, err := srv.Conns().PushToUser(ctx, 100, proto.MessageFromServer, &tg.UpdatesTooLong{})
|
||||
if err != nil {
|
||||
t.Fatalf("push to user: %v", err)
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("pushed to %d conns, want 1", sent)
|
||||
}
|
||||
r1 := collectReplies(t, conn1, cipher1, auth1.AuthKey, tg.UpdatesTooLongTypeID)
|
||||
mustHave(t, r1, tg.UpdatesTooLongTypeID, "pushed updates on conn1")
|
||||
|
||||
// 3) PushToUserExceptSession:模拟 SyncUpdatesNotMe,跳过当前 session。
|
||||
srv.Conns().BindUser(auth1.SessionID, 200)
|
||||
srv.Conns().BindUser(auth2.SessionID, 200)
|
||||
sent, err = srv.Conns().PushToUserExceptSession(ctx, 200, auth2.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{})
|
||||
if err != nil {
|
||||
t.Fatalf("push to user except session: %v", err)
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("pushed to %d conns, want 1 after excluding current session", sent)
|
||||
}
|
||||
r1 = collectReplies(t, conn1, cipher1, auth1.AuthKey, tg.UpdatesTooLongTypeID)
|
||||
mustHave(t, r1, tg.UpdatesTooLongTypeID, "pushed not-me updates on conn1")
|
||||
}
|
||||
|
||||
func BenchmarkSessionManagerOnlineCandidateFilter(b *testing.B) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(b))
|
||||
const online = 200_000
|
||||
rawPrefix := [8]byte{9}
|
||||
for i := 1; i <= online; i++ {
|
||||
raw := rawPrefix
|
||||
raw[1] = byte(i)
|
||||
raw[2] = byte(i >> 8)
|
||||
raw[3] = byte(i >> 16)
|
||||
raw[4] = byte(i >> 24)
|
||||
c := &Conn{sessionID: int64(i), authKeyID: raw}
|
||||
sm.Register(c)
|
||||
sm.BindUserForAuthKey(raw, int64(i), int64(i))
|
||||
}
|
||||
candidates := make([]int64, 0, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
candidates = append(candidates, int64(i*97+1))
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
got := sm.OnlineUserIDsForCandidates(candidates, 500)
|
||||
if len(got) == 0 {
|
||||
b.Fatal("no candidates matched")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue