Initial open source release

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

View file

@ -0,0 +1,2 @@
// Package account 是账号安全与设置应用服务。
package account

View 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
View file

@ -0,0 +1,5 @@
// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。
// 第一阶段用开发固定验证码2FA 配置由 account 服务持久化查询。
//
// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。
package auth

View 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
}

View 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)
}
}

View file

@ -0,0 +1,2 @@
// Package channels contains domain service orchestration for Telegram channels and supergroups.
package channels

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
// Package contacts 是联系人应用服务:联系人、拉黑、搜索。
// 第一阶段实现 PG-backed 空账号通讯录查询;联系人导入留后续业务迭代。
package contacts

View 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()
}

View 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)
}
}

View file

@ -0,0 +1,3 @@
// Package dialogs 是会话应用服务:会话列表、未读数、置顶、草稿。
// 第一阶段实现 PG-backed 空账号会话摘要查询;真实消息闭环留第二阶段。
package dialogs

View 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
}

View 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{}
}

View 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
}

View 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)
}
}

View 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
}

View 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)
}
})
}
}

View file

@ -0,0 +1,6 @@
// Package files 是文件应用服务upload 分片累积、blob 落盘、getFile 下载,
// 以及把上传文件组装成 Photo / Document头像、图片/文件/贴纸消息)。
//
// 类型边界:本包只用 domain / store 类型,不依赖 tg.*
// rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 的转换。
package files

View 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
View 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 setsdefault 系统集 + 常规集)
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),
}
// 主体 blobdoc:<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 缩略图jpgPhotoPathSize(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"`
}

View 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:])
}

View 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
View 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
View file

@ -0,0 +1,2 @@
// Package help 提供 help.* RPC 背后的数据目录服务。
package help

View 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 confighash 命中时返回 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"}},
},
},
},
}
}

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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.differenceTooLongapi_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))
}
}

View file

@ -0,0 +1,3 @@
// Package messages 是消息应用服务:发消息、编辑、删除、已读、历史记录。
// 第一阶段先实现官方系统会话所需的历史、搜索和已读最小闭环;第二阶段扩展私聊发送与推送。
package messages

View 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)
}

View 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)
}
}
// TestGetDifferenceStopsAtHolegetDifference 只返回从 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=truewant 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)
}
}
// TestGetStateReportsContiguousNotMaxgetState 报告最大连续 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)
}
}

View file

@ -0,0 +1,3 @@
// Package updates 是更新状态机与投递user 级 pts/qts/seq/date、离线差量updates.getDifference
// 在线推送。第一阶段持久化 auth_key 维度的初始空状态,消息事件队列留第二阶段。
package updates

View 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 logauth_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
}

View 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)
}

View file

@ -0,0 +1,3 @@
// Package users 是用户应用服务:用户资料、用户名、头像、在线状态。
// 第一阶段先支持 users.getUsers 返回自身。
package users

View 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()
}

View 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)
}

View 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,
}
}

View 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[:],
}
}

View 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 9caf32dffcv6.8.4+15Layer 225。
//
// TDesktop 兼容逻辑集中在这里,避免散落到业务服务中。
package tdesktop

View 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{},
}
}

View 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
View 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
}

View 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},
}
}

View file

@ -0,0 +1,15 @@
package domain
// Authorization 是一条设备授权auth_key 与 user 的绑定 + initConnection 设备信息。
// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。
type Authorization struct {
AuthKeyID [8]byte // 协议原生 auth_key_idstore 边界按小端转 int64
UserID int64
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
IP string
}

1546
internal/domain/channel.go Normal file

File diff suppressed because it is too large Load diff

View 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
}

View 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/supergroupChannelResults 放其他公开命中。
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
View 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
View 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
View 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
}

View 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
View 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_checksumhex可校验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
}

View 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
View 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
}

View 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")
)

View 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
View 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,
}
}

View 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
View file

@ -0,0 +1,10 @@
package domain
// UpdateState 是账号的 update 状态pts/qts/seq/date
// 第一阶段空账号为零值;真实状态机属第二阶段。
type UpdateState struct {
Pts int
Qts int
Date int
Seq int
}

View 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
View 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
}

View 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
View 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

View 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 {
// 发送已把全部积压攒满,此刻才启动 dispatcherdrain 阶段即纯排空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[:])
}

View 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 入队时才起 workerensureInboundRPCWorkers
// 避免握手后静默 / 纯推送目标连接白白钉住 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) }

View 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
}

View 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

View 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连本地 mtprotoedgeclient 自动经
// 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)
}
}

View 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_salterror_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
}
}

View 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()
}

View 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()}
}

View 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 验证 M1client 用 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) }()
// clientTCP 拨号 + 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
}

View 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
}

View 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
}
}
}

View 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)
}
}
}

View 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)
}
}

View 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) {}

View 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)
}
}

View 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。它不关闭底层 transporttransport 生命周期仍由 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队列拥塞时返回 ErrOutboundQueueFulldurable 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 为 nilfire-and-forgethandleOutboundSend 的 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
}

View 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)
}
}

View 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{}
}

View 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)
}

View 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
}

View 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")
}
}

View 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
// pendingPushMaxAgesession 注册后迟迟不调 updates.getStatereceivesUpdates 恒 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_idtemp 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]
// 过期保护:最早一条暂存已超过 pendingPushMaxAgesession 迟迟未 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)
}

View 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 故意不 startOutboundpushToUser 持锁快照 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
}

View 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) PushToSessionsession2 尚未进入 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
View file

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

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

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

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

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

View file

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

4017
internal/rpc/channels.go Normal file

File diff suppressed because it is too large Load diff

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

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

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

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

1919
internal/rpc/convert.go Normal file

File diff suppressed because it is too large Load diff

View file

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

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