perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View file

@ -490,8 +490,8 @@ func (r *Router) onAccountVerifyEmail(ctx context.Context, req *tg.AccountVerify
SentCode: tgEmailSentCode(p.PhoneCodeHash, domain.MaskEmail(email), len(strings.TrimSpace(code))),
}, nil
}
u, loginMessage, needSignUp, signInErr := r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), p.PhoneNumber, p.PhoneCodeHash, code)
authorization, err := r.finishAuthSignIn(ctx, u, loginMessage, needSignUp, signInErr)
u, _, needSignUp, signInErr := r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), p.PhoneNumber, p.PhoneCodeHash, code)
authorization, err := r.finishAuthSignIn(ctx, u, needSignUp, signInErr)
if err != nil {
return nil, err
}

View file

@ -490,7 +490,7 @@ func (r *Router) recordConnectedBusinessPeerSettings(ctx context.Context, userID
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID)
event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return err
}

View file

@ -51,10 +51,12 @@ func (r *Router) onAccountChangePhone(ctx context.Context, req *tg.AccountChange
return nil, authKeyUnregisteredErr()
}
sessionID, _ := SessionIDFrom(ctx)
originRawAuthKeyID := rawAuthKeyIDForOrigin(ctx)
result, err := r.deps.Account.ChangePhone(
ctx,
userID,
authKeyID,
originRawAuthKeyID,
sessionID,
req.PhoneNumber,
req.PhoneCodeHash,

View file

@ -6,31 +6,39 @@ import (
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"telesrv/internal/domain"
)
// TestLegacyThemeWireDecode 验证按 DrKLO 12.8.1 的 theme 构造器(比 gotd schema 新)
// 手写解码后能正确复用现有 handler。直接构造 DrKLO 的 wire 字节喂给 fallback compat 层。
func TestLegacyThemeWireDecode(t *testing.T) {
// TestLegacyThemeWireDispatch 验证 DrKLO theme 构造器经过 Router.Dispatch 的完整链路:
// layerwire 结构预检 -> gotd dispatcher fallback -> compat 解码 -> 现有 handler。
// 不能只直调 tryLegacyThemeRPC否则会掩盖预检早于 fallback 的路由回归。
func TestLegacyThemeWireDispatch(t *testing.T) {
const userID = 1000010
ctx := WithUserID(context.Background(), userID)
var authKeyID [8]byte
authKeyID[0] = 1
const sessionID = 99
files := &fakeFiles{docs: map[int64]domain.Document{
777: {ID: 777, AccessHash: 7, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 4096},
}}
r := newThemeRouter(t, files)
// createTheme 0x8432c21f:flags(=4,document) + slug + title + InputDocument
// createTheme 0x8432c21f:flags(document+single settings) + slug + title。
var cb bin.Buffer
cb.PutID(legacyCreateThemeID)
cb.PutInt32(1 << 2) // document present
cb.PutString("") // empty slug → auto
cb.PutInt32((1 << 2) | (1 << 3))
cb.PutString("") // empty slug → auto
cb.PutString("Legacy Theme")
(&tg.InputDocument{ID: 777, AccessHash: 7}).Encode(&cb)
(&tg.InputThemeSettings{BaseTheme: &tg.BaseThemeDay{}, AccentColor: 0x3997d3}).Encode(&cb)
enc, handled, err := r.tryLegacyThemeRPC(ctx, &cb)
if !handled || err != nil {
t.Fatalf("createTheme legacy = handled %v err %v", handled, err)
enc, err := r.Dispatch(ctx, authKeyID, sessionID, &cb)
if err != nil {
t.Fatalf("createTheme legacy dispatch: %v", err)
}
th, ok := enc.(*tg.Theme)
if !ok {
@ -44,9 +52,29 @@ func TestLegacyThemeWireDecode(t *testing.T) {
} else if d, _ := doc.(*tg.Document); d == nil || d.ID != 777 {
t.Fatalf("created theme document = %#v, want id 777", doc)
}
if settings, ok := th.GetSettings(); !ok || len(settings) != 1 || settings[0].AccentColor != 0x3997d3 {
t.Fatalf("created theme settings = %#v ok=%v, want one legacy setting", settings, ok)
}
mustEncodeTheme(t, th)
slug := th.Slug
// updateTheme 0x5cb367d5:flags(=2,title) + format + InputTheme + title。
var ub bin.Buffer
ub.PutID(legacyUpdateThemeID)
ub.PutInt32(1 << 1)
ub.PutString("android")
(&tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash}).Encode(&ub)
ub.PutString("Legacy Theme Updated")
enc, err = r.Dispatch(ctx, authKeyID, sessionID, &ub)
if err != nil {
t.Fatalf("updateTheme legacy dispatch: %v", err)
}
updated, ok := enc.(*tg.Theme)
if !ok || updated.Title != "Legacy Theme Updated" {
t.Fatalf("updateTheme legacy result = %#v, want updated title", enc)
}
// getTheme 0x8d9d742b:format + InputThemeSlug + document_id(被忽略)。
var gb bin.Buffer
gb.PutID(legacyGetThemeID)
@ -54,9 +82,9 @@ func TestLegacyThemeWireDecode(t *testing.T) {
(&tg.InputThemeSlug{Slug: slug}).Encode(&gb)
gb.PutLong(12345) // document_id ignored
enc, handled, err = r.tryLegacyThemeRPC(ctx, &gb)
if !handled || err != nil {
t.Fatalf("getTheme legacy = handled %v err %v", handled, err)
enc, err = r.Dispatch(ctx, authKeyID, sessionID, &gb)
if err != nil {
t.Fatalf("getTheme legacy dispatch: %v", err)
}
got, ok := enc.(*tg.Theme)
if !ok || got.Slug != slug {
@ -73,18 +101,44 @@ func TestLegacyThemeWireDecode(t *testing.T) {
ib.PutString("android")
(&tg.InputThemeSlug{Slug: slug}).Encode(&ib)
enc, handled, err = r.tryLegacyThemeRPC(ctx, &ib)
if !handled || err != nil {
t.Fatalf("installTheme legacy = handled %v err %v", handled, err)
enc, err = r.Dispatch(ctx, authKeyID, sessionID, &ib)
if err != nil {
t.Fatalf("installTheme legacy dispatch: %v", err)
}
if _, ok := enc.(*tg.BoolTrue); !ok {
t.Fatalf("installTheme legacy result = %T, want *tg.BoolTrue", enc)
}
// 非 theme 构造器 → 不处理。
var ob bin.Buffer
ob.PutID(0x12345678)
if _, handled, _ := r.tryLegacyThemeRPC(ctx, &ob); handled {
t.Fatalf("unrelated ctor should not be handled")
// 已声明 legacy 方法仍必须精确消费完整结构,截断字段不能到手写 decoder。
var malformed bin.Buffer
malformed.PutID(legacyCreateThemeID)
malformed.PutInt32(0)
malformed.PutString("slug") // missing title
if _, err := r.Dispatch(ctx, authKeyID, sessionID, &malformed); !tgerr.Is(err, "INPUT_REQUEST_INVALID") {
t.Fatalf("malformed legacy theme err = %v, want INPUT_REQUEST_INVALID", err)
}
}
func TestUnknownRPCReachesCompatibilityTraceAfterOpaquePreflight(t *testing.T) {
const unknownID = uint32(0x12345678)
r := newThemeRouter(t, &fakeFiles{})
r.deps.Auth = &captureAuthService{}
core, logs := observer.New(zap.WarnLevel)
r.log = zap.New(core)
var b bin.Buffer
b.PutID(unknownID)
// Deliberately resembles a forged vector count. Because the constructor is unknown, the
// body remains opaque and is never decoded or allocated from; total frame/RPC budgets bound it.
b.PutUint32(0xffffffff)
if _, err := r.Dispatch(context.Background(), [8]byte{1}, 101, &b); !tgerr.Is(err, "NOT_IMPLEMENTED") {
t.Fatalf("unknown dispatch err = %v, want NOT_IMPLEMENTED", err)
}
entries := logs.FilterMessage("Unhandled RPC (compatibility trace)").All()
if len(entries) != 1 {
t.Fatalf("compatibility trace entries = %d, want 1", len(entries))
}
if got, ok := entries[0].ContextMap()["type_id"]; !ok || got != "0x12345678" {
t.Fatalf("trace type_id = %#v, want %#x", got, unknownID)
}
}

View file

@ -0,0 +1,39 @@
package rpc
import (
"context"
"errors"
"go.uber.org/zap"
"telesrv/internal/domain"
)
func (r *Router) reserveAlbumGroup(ctx context.Context, userID int64, peer domain.Peer, items []domain.AlbumGroupReservationItem) (int64, error) {
reservations, ok := r.deps.Messages.(AlbumGroupService)
if !ok {
r.log.Error("messages.sendMultiMedia album reservation capability missing",
append(r.contextLogFields(ctx), zap.Int64("user_id", userID), zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID))...)
return 0, internalErr()
}
groupedID, err := reservations.ReserveAlbumGroup(ctx, userID, domain.AlbumGroupReservationRequest{
SenderUserID: userID,
Peer: peer,
Items: items,
ProposedGroupedID: randomNonZeroInt64(),
})
if errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
return 0, randomIDDuplicateErr()
}
if err != nil {
r.log.Error("messages.sendMultiMedia album reservation failed",
append(r.contextLogFields(ctx), zap.Error(err), zap.Int64("user_id", userID), zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Int("items", len(items)))...)
return 0, internalErr()
}
if groupedID == 0 {
r.log.Error("messages.sendMultiMedia album reservation returned zero grouped_id",
append(r.contextLogFields(ctx), zap.Int64("user_id", userID), zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID))...)
return 0, internalErr()
}
return groupedID, nil
}

View file

@ -241,6 +241,9 @@ func (r *Router) pushLoginTokenAccepted(ctx context.Context, target loginTokenTa
// 若该手机号账号设置了登录邮箱,验证码改投递到邮箱,返回 sentCodeTypeEmailCode
// (客户端据此进入"输入邮箱验证码"界面,随后用 auth.signIn 的 email_verification 完成登录)。
func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) {
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
r.rememberClientAPIID(ctx, req.APIID)
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
if err != nil {
@ -328,25 +331,21 @@ func (r *Router) tgSentCodeForHash(ctx context.Context, hash string) (tg.AuthSen
// 带 email_verification 时走登录邮箱路径(验证码来自邮箱而非短信)。
func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) {
var (
u domain.User
loginMessage domain.Message
needSignUp bool
err error
u domain.User
needSignUp bool
err error
)
if verification, ok := req.GetEmailVerification(); ok {
u, loginMessage, needSignUp, err = r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, emailVerificationCode(verification))
u, _, needSignUp, err = r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, emailVerificationCode(verification))
} else {
u, loginMessage, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
u, _, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode)
}
return r.finishAuthSignIn(ctx, u, loginMessage, needSignUp, err)
return r.finishAuthSignIn(ctx, u, needSignUp, err)
}
func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, loginMessage domain.Message, needSignUp bool, err error) (tg.AuthAuthorizationClass, error) {
func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, needSignUp bool, err error) (tg.AuthAuthorizationClass, error) {
if err != nil {
if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 {
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
// 两步验证未完成:绝不能把 auth_key/session 标记为已登录,否则客户端忽略
// SESSION_PASSWORD_NEEDED、直接调用业务 RPC 即可绕过 2FA。失效缓存并把 session
// 置为未授权,让后续鉴权重新读到 password_pending 并拒绝;待 checkPassword 通过后再授权。
@ -360,19 +359,18 @@ func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, loginMessa
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.enqueueLoginMessageBootstrap(ctx, loginMessage)
r.pushSignInServiceNotificationToOthers(ctx, u)
return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil
}
func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeRequest) (tg.AuthSentCodeClass, error) {
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
var hash string
var err error
if scoped, ok := r.deps.Auth.(interface {
@ -545,15 +543,34 @@ func (r *Router) completePendingPasswordSignIn(ctx context.Context, authKeyID [8
// onAuthResetLoginEmail 处理 auth.resetLoginEmail用户登录设备时无法访问登录邮箱时
// 清除登录邮箱,改回手机验证码登录,返回一个新的手机 sentCode 供其继续。
type loginEmailResetConsumer interface {
ConsumeLoginEmailReset(ctx context.Context, phone, phoneCodeHash string) (userID int64, err error)
SendPhoneCodeAfterLoginEmailReset(ctx context.Context, phone string, expectedUserID int64) (string, error)
}
func (r *Router) onAuthResetLoginEmail(ctx context.Context, req *tg.AuthResetLoginEmailRequest) (tg.AuthSentCodeClass, error) {
if r.deps.Account == nil || r.deps.Auth == nil {
return nil, internalErr()
}
if err := r.deps.Account.ClearLoginEmailByPhone(ctx, req.PhoneNumber); err != nil {
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
resetConsumer, ok := r.deps.Auth.(loginEmailResetConsumer)
if !ok {
return nil, internalErr()
}
hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber)
resetUserID, err := resetConsumer.ConsumeLoginEmailReset(ctx, req.PhoneNumber, req.PhoneCodeHash)
if err != nil {
return nil, signInErr(err)
}
if err := r.deps.Account.ClearLoginEmail(ctx, resetUserID); err != nil {
return nil, internalErr()
}
hash, err := resetConsumer.SendPhoneCodeAfterLoginEmailReset(ctx, req.PhoneNumber, resetUserID)
if err != nil {
if errors.Is(err, auth.ErrCodeExpired) || errors.Is(err, auth.ErrCodeInvalid) {
return nil, signInErr(err)
}
if errors.Is(err, auth.ErrPhoneNumberInvalid) ||
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
@ -564,7 +581,7 @@ func (r *Router) onAuthResetLoginEmail(ctx context.Context, req *tg.AuthResetLog
}
// emailVerificationCode 从 emailVerification 取出可校验的字符串(验证码 / Google·Apple
// 令牌)。开发环境一律按"任意非空即通过"处理,故三者等价取值
// 令牌);最终必须由 auth service 对签发记录精确校验
// onAuthInitPasskeyLogin 处理 auth.initPasskeyLogin生成一次性断言挑战discoverable
// 以 DataJSON顶层含 publicKey返回。免授权登录前
func (r *Router) onAuthInitPasskeyLogin(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) {
@ -579,7 +596,8 @@ func (r *Router) onAuthInitPasskeyLogin(ctx context.Context, req *tg.AuthInitPas
}
// onAuthFinishPasskeyLogin 处理 auth.finishPasskeyLogin验证登录断言并绑定 auth_key。
// 收尾与 signIn 同构清水位→授权缓存→session 绑定passkey 是强因子,直接完全授权
// 收尾与 signIn 同构Bind 原子切换 update baseline → 授权缓存 → session 绑定);
// passkey 是强因子,直接完全授权
// (不走 SESSION_PASSWORD_NEEDED。FromDCID/FromAuthKeyID 为多 DC 重路由用,本单 DC 忽略。
func (r *Router) onAuthFinishPasskeyLogin(ctx context.Context, req *tg.AuthFinishPasskeyLoginRequest) (tg.AuthAuthorizationClass, error) {
if r.deps.Passkey == nil || r.deps.Auth == nil {
@ -597,9 +615,6 @@ func (r *Router) onAuthFinishPasskeyLogin(ctx context.Context, req *tg.AuthFinis
if err != nil {
return nil, passkeyErr(err)
}
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
@ -621,7 +636,7 @@ func emailVerificationCode(v tg.EmailVerificationClass) string {
// onAuthImportBotAuthorization 处理 auth.importBotAuthorizationbot 程序凭 token
// 登录为 bot 账号。api_id/api_hash 与现有 sendCode 行为一致不校验(无 app 注册表)。
// 收尾与 signIn 同构(清水位→授权缓存→session 绑定),但不写登录消息、不推
// 收尾与 signIn 同构(Bind 原子切换 update baseline → 授权缓存 → session 绑定),但不写登录消息、不推
// signIn 服务通知——那是手机登录语义。
func (r *Router) onAuthImportBotAuthorization(ctx context.Context, req *tg.AuthImportBotAuthorizationRequest) (tg.AuthAuthorizationClass, error) {
if r.deps.Auth == nil {
@ -631,9 +646,6 @@ func (r *Router) onAuthImportBotAuthorization(ctx context.Context, req *tg.AuthI
if err != nil {
return nil, importBotAuthorizationErr(err)
}
if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil {
return nil, internalErr()
}
if id, ok := AuthKeyIDFrom(ctx); ok {
r.setAuthUserCache(id, u.ID, true)
}
@ -647,9 +659,6 @@ func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (t
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)
}
@ -689,18 +698,6 @@ func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) {
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
@ -770,8 +767,9 @@ func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u do
return
}
authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx)
rawAuthKeyID, hasRawAuthKeyID := RawAuthKeyIDFrom(ctx)
sessionID, hasSessionID := SessionIDFrom(ctx)
if !hasAuthKeyID || !hasSessionID {
if !hasAuthKeyID || !hasRawAuthKeyID || !hasSessionID {
return
}
notification := r.tgSignInServiceNotification(ctx, u, authKeyID)
@ -779,7 +777,7 @@ func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u do
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 {
if sent, err := scoped.PushToUserExceptAuthKeySession(pushCtx, u.ID, rawAuthKeyID, 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

View file

@ -0,0 +1,248 @@
package rpc
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/app/auth"
"telesrv/internal/domain"
)
type authCodeRateTestService struct {
*captureAuthService
sendCalls int
resendCalls int
resetCalls int
resetPhone string
resetHash string
resetUserID int64
resetErr error
}
func (s *authCodeRateTestService) SendCode(context.Context, string) (string, error) {
s.sendCalls++
return "send-hash", nil
}
func (s *authCodeRateTestService) ResendCode(context.Context, string, string) (string, error) {
s.resendCalls++
return "resend-hash", nil
}
func (s *authCodeRateTestService) ConsumeLoginEmailReset(_ context.Context, phone, hash string) (int64, error) {
s.resetCalls++
s.resetPhone = phone
s.resetHash = hash
return s.resetUserID, s.resetErr
}
func (s *authCodeRateTestService) SendPhoneCodeAfterLoginEmailReset(_ context.Context, _ string, expectedUserID int64) (string, error) {
s.sendCalls++
if expectedUserID != s.resetUserID {
return "", auth.ErrCodeInvalid
}
return "send-hash", nil
}
type authCodeRateTestAccount struct {
AccountService
clearCalls int
clearUserID int64
}
func (s *authCodeRateTestAccount) ClearLoginEmail(_ context.Context, userID int64) error {
s.clearCalls++
s.clearUserID = userID
return nil
}
func TestAuthSendCodeRateLimitUsesOpaquePhoneAndRawAuthKeyKeys(t *testing.T) {
phone := "+1 (555) 123-4567"
rawAuthKeyID := [8]byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}
limiter := &captureRateLimiter{}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
r := New(Config{
AuthCodePhoneRateLimit: 5,
AuthCodeAuthKeyRateLimit: 20,
AuthCodeRateWindow: 10 * time.Minute,
}, Deps{Auth: authService, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
ctx := WithRawAuthKeyID(context.Background(), rawAuthKeyID)
if _, err := r.onAuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 2040}); err != nil {
t.Fatalf("onAuthSendCode: %v", err)
}
if authService.sendCalls != 1 {
t.Fatalf("SendCode calls = %d, want 1", authService.sendCalls)
}
if len(limiter.calls) != 2 {
t.Fatalf("limiter calls = %d, want phone + raw auth key", len(limiter.calls))
}
digest := sha256.Sum256([]byte(domain.NormalizePhone(phone)))
wantPhoneKey := authCodePhoneRateLimitKeyPrefix + hex.EncodeToString(digest[:])
wantAuthKey := authCodeAuthKeyRateLimitKeyPrefix + hex.EncodeToString(rawAuthKeyID[:])
if got := limiter.calls[0]; got.key != wantAuthKey || got.cost != 1 || got.limit != 20 || got.window != 10*time.Minute {
t.Fatalf("auth-key limiter call = %+v", got)
}
if got := limiter.calls[1]; got.key != wantPhoneKey || got.cost != 1 || got.limit != 5 || got.window != 10*time.Minute {
t.Fatalf("phone limiter call = %+v", got)
}
for _, call := range limiter.calls {
if strings.Contains(call.key, domain.NormalizePhone(phone)) || strings.Contains(call.key, phone) {
t.Fatalf("limiter key leaked phone: %q", call.key)
}
}
}
func TestAuthSendCodePhoneRateLimitPrecedesBusinessLookupAndWrite(t *testing.T) {
limiter := &captureRateLimiter{block: true, retryAfter: 17}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
r := New(Config{
AuthCodePhoneRateLimit: 5,
AuthCodeRateWindow: time.Minute,
}, Deps{Auth: authService, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
_, err := r.onAuthSendCode(WithRawAuthKeyID(context.Background(), [8]byte{1}), &tg.AuthSendCodeRequest{PhoneNumber: "+86 188 0000 0000", APIID: 2040})
if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") || !strings.Contains(err.Error(), "(17)") {
t.Fatalf("sendCode err = %v, want FLOOD_WAIT 17", err)
}
if authService.sendCalls != 0 {
t.Fatalf("SendCode calls = %d, want 0", authService.sendCalls)
}
if len(authService.authKeyClientInfos) != 0 {
t.Fatalf("blocked sendCode persisted client info: %+v", authService.authKeyClientInfos)
}
if len(limiter.calls) != 1 || !strings.HasPrefix(limiter.calls[0].key, authCodePhoneRateLimitKeyPrefix) {
t.Fatalf("limiter calls = %+v, want only phone dimension", limiter.calls)
}
}
func TestAuthSendCodeRawAuthKeyBlockDoesNotCreatePhoneDimension(t *testing.T) {
limiter := &captureRateLimiter{block: true, retryAfter: 31}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
r := New(Config{
AuthCodePhoneRateLimit: 5,
AuthCodeAuthKeyRateLimit: 20,
AuthCodeRateWindow: time.Minute,
}, Deps{Auth: authService, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
rawAuthKeyID := [8]byte{7, 7, 7, 7, 7, 7, 7, 7}
_, err := r.onAuthSendCode(WithRawAuthKeyID(context.Background(), rawAuthKeyID), &tg.AuthSendCodeRequest{PhoneNumber: "15550000001"})
if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") {
t.Fatalf("sendCode err = %v, want FLOOD_WAIT", err)
}
wantKey := authCodeAuthKeyRateLimitKeyPrefix + hex.EncodeToString(rawAuthKeyID[:])
if len(limiter.calls) != 1 || limiter.calls[0].key != wantKey {
t.Fatalf("limiter calls = %+v, want only raw auth-key %q", limiter.calls, wantKey)
}
if authService.sendCalls != 0 {
t.Fatalf("SendCode calls = %d, want 0", authService.sendCalls)
}
}
func TestAuthSendCodeInvalidPhoneCreatesNoLimiterKey(t *testing.T) {
limiter := &captureRateLimiter{}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
r := New(Config{
AuthCodePhoneRateLimit: 5,
AuthCodeAuthKeyRateLimit: 20,
AuthCodeRateWindow: time.Minute,
}, Deps{Auth: authService, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
_, err := r.onAuthSendCode(WithRawAuthKeyID(context.Background(), [8]byte{1}), &tg.AuthSendCodeRequest{PhoneNumber: "not-a-phone"})
if err == nil || !strings.Contains(err.Error(), "PHONE_NUMBER_INVALID") {
t.Fatalf("sendCode err = %v, want PHONE_NUMBER_INVALID", err)
}
if len(limiter.calls) != 0 || authService.sendCalls != 0 {
t.Fatalf("invalid phone limiter/service calls = %d/%d, want 0/0", len(limiter.calls), authService.sendCalls)
}
}
func TestAuthResendCodeRawAuthKeyRateLimitPrecedesRotation(t *testing.T) {
limiter := &captureRateLimiter{block: true, retryAfter: 23}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
r := New(Config{
AuthCodeAuthKeyRateLimit: 20,
AuthCodeRateWindow: 2 * time.Minute,
}, Deps{Auth: authService, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
rawAuthKeyID := [8]byte{9, 8, 7, 6, 5, 4, 3, 2}
_, err := r.onAuthResendCode(WithRawAuthKeyID(context.Background(), rawAuthKeyID), &tg.AuthResendCodeRequest{
PhoneNumber: "8618800000000",
PhoneCodeHash: "old-hash",
})
if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") || !strings.Contains(err.Error(), "(23)") {
t.Fatalf("resendCode err = %v, want FLOOD_WAIT 23", err)
}
if authService.resendCalls != 0 {
t.Fatalf("ResendCode calls = %d, want 0", authService.resendCalls)
}
wantKey := authCodeAuthKeyRateLimitKeyPrefix + hex.EncodeToString(rawAuthKeyID[:])
if len(limiter.calls) != 1 || limiter.calls[0].key != wantKey {
t.Fatalf("limiter calls = %+v, want %q", limiter.calls, wantKey)
}
}
func TestAuthResetLoginEmailRateLimitPrecedesEmailClear(t *testing.T) {
limiter := &captureRateLimiter{block: true, retryAfter: 29}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
accountService := &authCodeRateTestAccount{}
r := New(Config{AuthCodePhoneRateLimit: 5, AuthCodeRateWindow: time.Minute}, Deps{
Auth: authService, Account: accountService, Limiter: limiter,
}, zaptest.NewLogger(t), clock.System)
_, err := r.onAuthResetLoginEmail(context.Background(), &tg.AuthResetLoginEmailRequest{
PhoneNumber: "8618800000000",
PhoneCodeHash: "email-hash",
})
if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") || !strings.Contains(err.Error(), "(29)") {
t.Fatalf("resetLoginEmail err = %v, want FLOOD_WAIT 29", err)
}
if accountService.clearCalls != 0 || authService.resetCalls != 0 || authService.sendCalls != 0 {
t.Fatalf("side effects reset=%d clear=%d send=%d, want 0/0/0", authService.resetCalls, accountService.clearCalls, authService.sendCalls)
}
}
func TestAuthResetLoginEmailConsumesHashBeforeClearAndResend(t *testing.T) {
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}, resetUserID: 4242}
accountService := &authCodeRateTestAccount{}
r := New(Config{}, Deps{Auth: authService, Account: accountService}, zaptest.NewLogger(t), clock.System)
req := &tg.AuthResetLoginEmailRequest{PhoneNumber: "+1 555 123 9999", PhoneCodeHash: "email-login-hash"}
result, err := r.onAuthResetLoginEmail(context.Background(), req)
if err != nil {
t.Fatalf("onAuthResetLoginEmail: %v", err)
}
if authService.resetCalls != 1 || authService.resetPhone != req.PhoneNumber || authService.resetHash != req.PhoneCodeHash ||
accountService.clearCalls != 1 || accountService.clearUserID != authService.resetUserID || authService.sendCalls != 1 {
t.Fatalf("calls reset=%d(%q,%q uid=%d) clear=%d(uid=%d) send=%d", authService.resetCalls, authService.resetPhone, authService.resetHash, authService.resetUserID, accountService.clearCalls, accountService.clearUserID, authService.sendCalls)
}
sent, ok := result.(*tg.AuthSentCode)
if !ok || sent.PhoneCodeHash != "send-hash" {
t.Fatalf("result=%T %+v, want sentCode/send-hash", result, result)
}
}
func TestAuthResetLoginEmailInvalidHashNeverClearsFactor(t *testing.T) {
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}, resetErr: auth.ErrCodeExpired}
accountService := &authCodeRateTestAccount{}
r := New(Config{}, Deps{Auth: authService, Account: accountService}, zaptest.NewLogger(t), clock.System)
_, err := r.onAuthResetLoginEmail(context.Background(), &tg.AuthResetLoginEmailRequest{
PhoneNumber: "15551239999",
PhoneCodeHash: "expired-email-hash",
})
if err == nil || !strings.Contains(err.Error(), "PHONE_CODE_EXPIRED") {
t.Fatalf("onAuthResetLoginEmail err=%v, want PHONE_CODE_EXPIRED", err)
}
if authService.resetCalls != 1 || accountService.clearCalls != 0 || authService.sendCalls != 0 {
t.Fatalf("calls reset=%d clear=%d send=%d, want 1/0/0", authService.resetCalls, accountService.clearCalls, authService.sendCalls)
}
}

View file

@ -107,15 +107,16 @@ func TestAuthLoginTokenAcceptedByAndroidBindsTargetSession(t *testing.T) {
if snap.sessionID != targetSession || snap.userID != scannerUserID || !snap.userResolved {
t.Fatalf("target session snapshot = %+v, want session/user/resolved %d/%d/true", snap, targetSession, scannerUserID)
}
if snap.messageType != proto.MessageFromServer {
t.Fatalf("push message type = %v, want MessageFromServer", snap.messageType)
}
if !sessions.immediatePushSeen() {
t.Fatal("login token update was not pushed through the immediate pre-auth path")
}
short, ok := snap.message.(*tg.UpdateShort)
immediateType, immediateMessage := sessions.immediatePushSnapshot()
if immediateType != proto.MessageFromServer {
t.Fatalf("immediate push message type = %v, want MessageFromServer", immediateType)
}
short, ok := immediateMessage.(*tg.UpdateShort)
if !ok {
t.Fatalf("push message = %T, want *tg.UpdateShort", snap.message)
t.Fatalf("immediate push message = %T, want *tg.UpdateShort", immediateMessage)
}
if _, ok := short.Update.(*tg.UpdateLoginToken); !ok {
t.Fatalf("pushed update = %T, want *tg.UpdateLoginToken", short.Update)

View file

@ -151,7 +151,6 @@ func (r *Router) sendBotAllowedServiceMessageWith(ctx context.Context, userID, b
if err != nil {
return domain.SendPrivateTextResult{}, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
@ -165,7 +164,7 @@ func (r *Router) sendBotAllowedServiceMessageWith(ctx context.Context, userID, b
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})

View file

@ -2,8 +2,10 @@ package rpc
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/gotd/td/tg"
"go.uber.org/zap"
@ -23,12 +25,28 @@ import (
// DrKLO Android ~1.5s 乱序窗口与 TDesktop PtsWaiter 的连续性期望(设计 §10.2)。
// - 单实例 + 无 durable 重投队列 + 同 channel 串行 → 无乱序、无自重复,故 v1 不需要
// per-session at-most-once 双水位(那是 Phase 3 跨实例的事,设计 §9/§10.1)。
// - 有界队列满时丢弃当前 job 并告警:被丢 recipient 会在该 channel 下一条成功投递的
// pts 跳变时经 getChannelDifference 收敛(设计约束 B
// - 有界队列满时不静默丢弃恢复触发:真实 payload job 降级为按 channel 合并、只保留
// 最高 pts 的 UpdateChannelTooLong nudge。每个 shard 独立公平 drain即使该频道随后
// 静默,也不依赖“下一条消息”才能触发 getChannelDifference设计约束 B
const (
defaultChannelFanoutShards = 64
defaultChannelFanoutBuffer = 2048
// The old 64x2048 channel buffers eagerly retained up to 131k closures (each may capture a
// message batch and ~2k recipients). Keep one small FIFO per ordering shard and enforce a
// process-wide retained-byte budget below.
defaultChannelFanoutBuffer = 64
defaultChannelFanoutMaxQueuedJobs = 4096
defaultChannelFanoutMaxQueuedBytes = 256 << 20
defaultChannelFanoutOverflowPerShard = 256
defaultChannelNudgeWorkers = 8
defaultChannelNudgeQueue = 4096
defaultChannelFanoutRecoverySweepPage = 256
channelFanoutMinRetainedBytes int64 = 64 << 10
channelFanoutNudgeRetryMin = time.Millisecond
channelFanoutNudgeRetryMax = 50 * time.Millisecond
channelFanoutRecoveryRetryMin = 10 * time.Millisecond
channelFanoutRecoveryRetryMax = time.Second
defaultChannelFanoutNudgeDeadline = 5 * time.Second
)
// channelFanoutBuilder 按 viewer 构建该 viewer 视角的 channel updates。与同步
@ -37,7 +55,7 @@ const (
type channelFanoutBuilder func(ctx context.Context, viewerUserID int64) *tg.Updates
// channelFanoutJob 是一条频道 payload fan-out 任务。Pts 仅用于日志/折叠语义;真值仍是
// channel_update_eventsworker 只做在线投递。originAuthKeyID 是业务视角 auth key
// channel_update_eventsworker 只做在线投递。originAuthKeyID 是物理 raw auth key
// (与 SessionManager.shouldExcludeSession 的比较侧一致),用于显式排除发起设备——异步
// 执行时请求 ctx 已失效,不能再靠 ctx 派生排除。
type channelFanoutJob struct {
@ -50,6 +68,270 @@ type channelFanoutJob struct {
originSessionID int64
prefetch channelFanoutPrefetch
build channelFanoutBuilder
// retainedBytes is a conservative reservation for the request-derived closure, result
// snapshots and explicit recipient slice. It is charged before the job enters any queue.
retainedBytes int64
// queueSeq 是 dispatcher shard 内部的 FIFO 序号。仅成功进入正常 payload queue 的
// job 占用序号overflow watermark 记录入队失败时已经接受的最大序号,等这些更早
// payload 处理完后才发 nudge避免 nudge 越过其之前的正常 FIFO payload。
queueSeq uint64
}
// channelFanoutOverflow 是 queue full 时的 nudge-only 恢复水位。同一 channel 只保留
// 最大 ptsbarrier 是该次 overflow 之前已经进入正常 FIFO 的最后一个 shard 序号。
type channelFanoutOverflow struct {
pts int
barrier uint64
}
// channelFanoutShard 把正常 payload FIFO 与 overflow nudge mailbox 放在同一个 worker
// 下。overflowOrder 每个 channel 最多出现一次;热点 channel 只更新 map 水位,不会占满
// order从而不能把其它 channel 的唯一恢复 nudge 永久饿死。
type channelFanoutShard struct {
jobs chan channelFanoutJob
overflowWake chan struct{}
// overflowSpace is a generation channel, not a one-token notification. A slot
// release closes the current generation and installs a fresh channel while mu is
// held, waking every waiter that observed the old full mailbox. Each waiter then
// competes under mu for the actually available slots; losers observe the new
// generation and sleep again. This avoids losing N-1 wakeups when several slots
// are released before any of N waiters gets scheduled.
overflowSpace chan struct{}
mu sync.Mutex
nextSeq uint64
processedSeq uint64
overflow map[int64]channelFanoutOverflow
overflowOrder []int64
overflowLimit int
// overflowWaiters is guarded by mu and only covers the distinct-channel
// saturation slow path. Besides making the wait lifecycle explicit, it avoids
// allocating a fresh generation channel when no goroutine is subscribed.
overflowWaiters int
}
func newChannelFanoutShard(buffer int) *channelFanoutShard {
return &channelFanoutShard{
jobs: make(chan channelFanoutJob, buffer),
overflowWake: make(chan struct{}, 1),
overflowSpace: make(chan struct{}),
overflow: make(map[int64]channelFanoutOverflow),
overflowLimit: defaultChannelFanoutOverflowPerShard,
}
}
// enqueue 尝试把真实 payload 放入正常 FIFO满时按 channel 合并最高 pts 的 nudge-only
// watermark。返回 true 表示正常入队false 表示已经安全降级为 overflow watermark。
func (s *channelFanoutShard) enqueue(job channelFanoutJob) bool {
s.mu.Lock()
job.queueSeq = s.nextSeq + 1
select {
case s.jobs <- job:
s.nextSeq = job.queueSeq
s.mu.Unlock()
return true
default:
s.mu.Unlock()
return false
}
}
func (s *channelFanoutShard) enqueueOverflow(channelID int64, pts int) bool {
s.mu.Lock()
accepted := s.addOverflowLocked(channelID, pts)
s.mu.Unlock()
if accepted {
s.signalOverflow()
}
return accepted
}
func (s *channelFanoutShard) enqueueOverflowWait(ctx context.Context, channelID int64, pts int, stop <-chan struct{}) bool {
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
return false
case <-stop:
return false
default:
}
// Try and capture the current space generation under the same lock. Separating
// these operations creates a classic missed-wakeup window: a drain may free the
// mailbox after the failed try but before the waiter starts observing the signal.
s.mu.Lock()
if s.addOverflowLocked(channelID, pts) {
s.mu.Unlock()
s.signalOverflow()
return true
}
space := s.overflowSpace
s.overflowWaiters++
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.overflowWaiters--
s.mu.Unlock()
}()
// Same-channel overflow is the hot saturation path and only updates an existing map item.
// Distinct-channel saturation may wait here only from the dispatcher's fixed recovery sweep
// actor. RPC producers never call this method: they publish an O(1) global recovery generation
// when the bounded mailbox is full, so request goroutines cannot be exhausted by fan-out
// admission pressure.
for {
// Cardinality is full with distinct channels. Apply bounded-memory backpressure instead of
// allocating an unbounded recovery map or dropping the recovery watermark.
select {
case <-space:
case <-ctx.Done():
return false
case <-stop:
return false
}
// Do not turn a release racing with cancellation into admission after the caller ended.
select {
case <-ctx.Done():
return false
case <-stop:
return false
default:
}
// Retrying and subscribing to the next generation must also be atomic with
// respect to a release. Broadcast wakeups can be spurious for a particular
// waiter (another waiter may win the sole slot), so loop until accepted or stopped.
s.mu.Lock()
if s.addOverflowLocked(channelID, pts) {
s.mu.Unlock()
s.signalOverflow()
return true
}
space = s.overflowSpace
s.mu.Unlock()
}
}
func (s *channelFanoutShard) addOverflowLocked(channelID int64, pts int) bool {
item, exists := s.overflow[channelID]
if !exists {
if len(s.overflow) >= s.overflowLimit {
return false
}
s.overflowOrder = append(s.overflowOrder, channelID)
// The first overflow fixes the FIFO barrier. Later same-channel losses only raise the
// durable pts watermark: moving the barrier on every merge lets a continuously full
// payload queue keep the recovery nudge one slot behind forever. UpdateChannelTooLong is
// an idempotent catch-up trigger, so it is safe for its newest pts to overtake payloads
// accepted after the first loss; those payloads become harmless duplicates after
// getChannelDifference converges the client.
item.barrier = s.nextSeq
}
if pts > item.pts {
item.pts = pts
}
s.overflow[channelID] = item
return true
}
func (s *channelFanoutShard) markProcessed(seq uint64) {
s.mu.Lock()
if seq > s.processedSeq {
s.processedSeq = seq
}
s.mu.Unlock()
}
// signalOverflowSpaceLocked announces a mailbox-cardinality decrease. Callers
// must hold s.mu. Close-and-replace provides broadcast generations without an
// unbounded waiter list, goroutine-per-waiter, or lossy fixed-capacity token queue.
func (s *channelFanoutShard) signalOverflowSpaceLocked() {
if s.overflowWaiters == 0 {
return
}
close(s.overflowSpace)
s.overflowSpace = make(chan struct{})
}
// popOverflow 仅供 mailbox/cardinality 单元测试直接释放一个 overflow生产 drain 必须走
// tryQueueOverflow确保 nudgeJobs 真正接收成功前不删除水位。
func (s *channelFanoutShard) popOverflow() (channelID int64, pts int, ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
for remaining := len(s.overflowOrder); remaining > 0; remaining-- {
channelID = s.overflowOrder[0]
s.overflowOrder = s.overflowOrder[1:]
item, exists := s.overflow[channelID]
if !exists {
continue
}
if item.barrier > s.processedSeq {
s.overflowOrder = append(s.overflowOrder, channelID)
continue
}
delete(s.overflow, channelID)
s.signalOverflowSpaceLocked()
return channelID, item.pts, true
}
return 0, 0, false
}
// tryQueueOverflow 尝试把一个 barrier 已满足的 overflow 水位非阻塞提交给共享 nudge queue。
// 只有 channel send 成功才从 mailbox 删除queue 满时保留原 item包含并发合并后的最高 pts
// 和原 order 位置。整个操作在 shard.mu 下完成,因此不会出现“读到旧 pts 后删除新 pts”的竞态。
func (s *channelFanoutShard) tryQueueOverflow(offer func(channelFanoutNudge) bool) (queued, blocked bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i := 0; i < len(s.overflowOrder); {
channelID := s.overflowOrder[i]
item, exists := s.overflow[channelID]
if !exists {
copy(s.overflowOrder[i:], s.overflowOrder[i+1:])
s.overflowOrder = s.overflowOrder[:len(s.overflowOrder)-1]
continue
}
if item.barrier > s.processedSeq {
i++
continue
}
if offer(channelFanoutNudge{channelID: channelID, pts: item.pts}) {
delete(s.overflow, channelID)
copy(s.overflowOrder[i:], s.overflowOrder[i+1:])
s.overflowOrder = s.overflowOrder[:len(s.overflowOrder)-1]
s.signalOverflowSpaceLocked()
return true, false
} else {
// Shared nudge workers are saturated. Keep the exact watermark and retry from
// the shard's bounded timer; do not remove or advance the mailbox entry, and
// never park this payload worker on the nudge queue.
return false, true
}
}
return false, false
}
func (s *channelFanoutShard) signalOverflow() {
select {
case s.overflowWake <- struct{}{}:
default:
}
}
func (s *channelFanoutShard) signalEligibleOverflow() {
s.mu.Lock()
eligible := false
for _, channelID := range s.overflowOrder {
if item, ok := s.overflow[channelID]; ok && item.barrier <= s.processedSeq {
eligible = true
break
}
}
s.mu.Unlock()
if eligible {
s.signalOverflow()
}
}
// channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次,
@ -60,30 +342,72 @@ type channelFanoutPrefetch func(ctx context.Context, viewers []int64)
// channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC按 channelID 分片串行处理。
type channelFanoutDispatcher struct {
r *Router
log *zap.Logger
shards []chan channelFanoutJob
started atomic.Bool
r *Router
log *zap.Logger
shards []*channelFanoutShard
started atomic.Bool
stopped atomic.Bool
stopCh chan struct{}
stopOnce sync.Once
enqueueMu sync.RWMutex
budgetMu sync.Mutex
queuedJobs int
queuedBytes int64
maxQueuedJobs int
maxQueuedBytes int64
nudgeJobs chan int64
nudgeWorkers int
nudgeTimeout time.Duration
nudgeMu sync.Mutex
// nudgePending and nudgeJobs form one bounded coalescing mailbox. nudgeJobs contains only
// channel ids; the mutable map value always holds the highest pts observed before a worker
// takes that id. A hot channel therefore occupies one slot rather than filling the queue.
nudgePending map[int64]int
nudgeLimit int
// recoveryGeneration is the terminal in-memory saturation fallback. It deliberately carries
// no channel id: the fixed recovery actor enumerates the online membership index and reloads
// each channel's durable max pts. Thus even when every key-bearing mailbox is full, publishing
// one constant-size generation cannot fail or block an RPC producer.
recoveryGeneration atomic.Uint64
recoveryCompleted atomic.Uint64
recoveryWake chan struct{}
// dropped 保留旧字段名供既有统计兼容;现在表示“真实 payload 因 queue full 被折叠为
// nudge-only overflow watermark”的次数不再表示恢复触发也被静默丢弃。
dropped atomic.Int64
}
type channelFanoutNudge struct {
channelID int64
pts int
}
// enqueueChannelFanout 把一条 channel-payload-pts 的 fan-out 投入异步 dispatcher。
// 从请求 ctx 抓取发起设备的业务 auth key + session_id 显式带入 job使异步 worker 仍能
// 从请求 ctx 抓取发起设备的 raw auth key + session_id 显式带入 job使异步 worker 仍能
// 排除发起设备回显(请求 ctx 异步时已失效)。仅用于会推进客户端 channel PtsWaiter 的真实
// payload新消息/编辑/删除/pinreaction/pollviewer-only 零 pts、participant/TTL/
// channel state无 channel pts、typingtransient不走此路径设计 §2.1/§5 分类)。
func (r *Router) enqueueChannelFanout(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, build channelFanoutBuilder) {
r.enqueueChannelFanoutWithPrefetch(ctx, scope, originUserID, channelID, pts, recipients, nil, build)
r.enqueueChannelFanoutWithPrefetch(ctx, scope, originUserID, channelID, pts, recipients, 0, nil, build)
}
// enqueueChannelFanoutWithPrefetch 同 enqueueChannelFanout但额外带一个跨 viewer 用户投影预热钩子
// fan-out 模板化把每 recipient 的逐 viewer 投影折叠成一次 O(owner) 投影;见 prefetchChannelFanoutUsers
func (r *Router) enqueueChannelFanoutWithPrefetch(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, prefetch channelFanoutPrefetch, build channelFanoutBuilder) {
func (r *Router) enqueueChannelFanoutWithPrefetch(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, retainedFloor int64, prefetch channelFanoutPrefetch, build channelFanoutBuilder) {
if r.channelFanout == nil || build == nil {
return
}
originAuthKeyID, _ := AuthKeyIDFrom(ctx)
originAuthKeyID := rawAuthKeyIDForOrigin(ctx)
originSessionID, _ := SessionIDFrom(ctx)
retainedBytes := int64(inboundRPCBytesFrom(ctx)) + int64(len(recipients))*8 + 4096
if retainedFloor < channelFanoutMinRetainedBytes {
retainedFloor = channelFanoutMinRetainedBytes
}
if retainedBytes < retainedFloor {
retainedBytes = retainedFloor
}
r.channelFanout.Enqueue(ctx, channelFanoutJob{
scope: scope,
originUserID: originUserID,
@ -94,6 +418,7 @@ func (r *Router) enqueueChannelFanoutWithPrefetch(ctx context.Context, scope cha
originSessionID: originSessionID,
prefetch: prefetch,
build: build,
retainedBytes: retainedBytes,
})
}
@ -110,9 +435,22 @@ func newChannelFanoutDispatcher(r *Router, shards, buffer int) *channelFanoutDis
if buffer <= 0 {
buffer = defaultChannelFanoutBuffer
}
d := &channelFanoutDispatcher{r: r, log: r.log.Named("channel-fanout"), shards: make([]chan channelFanoutJob, shards)}
d := &channelFanoutDispatcher{
r: r,
log: r.log.Named("channel-fanout"),
shards: make([]*channelFanoutShard, shards),
stopCh: make(chan struct{}),
maxQueuedJobs: defaultChannelFanoutMaxQueuedJobs,
maxQueuedBytes: defaultChannelFanoutMaxQueuedBytes,
nudgeJobs: make(chan int64, defaultChannelNudgeQueue),
nudgeWorkers: defaultChannelNudgeWorkers,
nudgeTimeout: defaultChannelFanoutNudgeDeadline,
nudgePending: make(map[int64]int),
nudgeLimit: defaultChannelNudgeQueue,
recoveryWake: make(chan struct{}, 1),
}
for i := range d.shards {
d.shards[i] = make(chan channelFanoutJob, buffer)
d.shards[i] = newChannelFanoutShard(buffer)
}
return d
}
@ -124,22 +462,138 @@ func (d *channelFanoutDispatcher) Run(ctx context.Context) {
return
}
var wg sync.WaitGroup
for i := range d.shards {
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
d.enqueueMu.Lock()
d.stopped.Store(true)
d.stopOnce.Do(func() { close(d.stopCh) })
d.enqueueMu.Unlock()
}()
wg.Add(1)
go func() {
defer wg.Done()
d.runRecoverySweeps(ctx)
}()
for range d.nudgeWorkers {
wg.Add(1)
ch := d.shards[i]
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job := <-ch:
case channelID := <-d.nudgeJobs:
nudge, ok := d.takeNudge(channelID)
if !ok {
continue
}
timeout := d.nudgeTimeout
if timeout <= 0 {
timeout = defaultChannelFanoutNudgeDeadline
}
nudgeCtx, cancel := context.WithTimeout(ctx, timeout)
complete := d.r.runChannelFanoutOverflowNudge(nudgeCtx, nudge.channelID, nudge.pts)
cancel()
if !complete && ctx.Err() == nil {
// A deadline may leave only a prefix of online members nudged. Do not try to
// remember that recipient subset: request a durable max-pts sweep instead.
d.requestRecoverySweep("nudge deadline")
}
}
}
}()
}
for i := range d.shards {
wg.Add(1)
shard := d.shards[i]
go func() {
defer wg.Done()
var retryTimer *time.Timer
var retryC <-chan time.Time
retryDelay := channelFanoutNudgeRetryMin
stopRetryTimer := func() {
if retryTimer != nil {
retryTimer.Stop()
}
}
defer stopRetryTimer()
scheduleRetry := func() {
if retryC != nil {
return
}
if retryTimer == nil {
retryTimer = time.NewTimer(retryDelay)
} else {
retryTimer.Reset(retryDelay)
}
retryC = retryTimer.C
if retryDelay < channelFanoutNudgeRetryMax {
retryDelay *= 2
if retryDelay > channelFanoutNudgeRetryMax {
retryDelay = channelFanoutNudgeRetryMax
}
}
}
drain := func() {
// While a retry is armed, payload completions and coalescing wakeups must not
// defeat backoff and spin on a full shared queue.
if retryC != nil {
return
}
queued, blocked := d.drainOneOverflow(shard)
if queued {
retryDelay = channelFanoutNudgeRetryMin
shard.signalEligibleOverflow()
return
}
if blocked {
scheduleRetry()
}
}
for {
select {
case <-ctx.Done():
return
case job := <-shard.jobs:
d.r.runChannelFanoutJob(ctx, job)
d.releaseQueuedJob(job)
shard.markProcessed(job.queueSeq)
// 每处理一条正常 FIFO payload主动尝试 drain 一条已经越过
// barrier 的 overflow。这样持续灌满正常队列的热点频道也不能
// 永久饿死其它频道的恢复 nudge。
drain()
case <-shard.overflowWake:
drain()
case <-retryC:
retryC = nil
drain()
}
}
}()
}
wg.Wait()
// Workers may choose ctx.Done while jobs remain buffered. Release every reservation and
// drop closure references so tests/restarts do not retain the global budget after shutdown.
for _, shard := range d.shards {
for {
select {
case job := <-shard.jobs:
d.releaseQueuedJob(job)
default:
goto drained
}
}
drained:
shard.mu.Lock()
clear(shard.overflow)
shard.overflowOrder = nil
shard.mu.Unlock()
}
d.nudgeMu.Lock()
clear(d.nudgePending)
d.nudgeMu.Unlock()
}
func (d *channelFanoutDispatcher) shardIndex(channelID int64) int {
@ -152,8 +606,10 @@ func (d *channelFanoutDispatcher) shardIndex(channelID int64) int {
}
// Enqueue 投递一条 fan-out 任务。dispatcher 未启动时同步执行(用请求 ctx保持旧行为
// 已启动时投入对应分片,满则丢弃 + 告警(该 channel 下一条消息的 pts 跳变会经
// getChannelDifference 兜底)。
// 已启动时投入对应分片。满时正常 payload 不阻塞请求路径,而是按 channel 合并为最高 pts
// 的 nudge-only overflow watermark由同 shard worker 在更早的 FIFO payload 后公平 drain。
// 若 overflow cardinality 也已满,只发布一个常量大小的全局 recovery generation固定后台
// actor 随后从 durable channel pts 重建全部在线 channel 的 nudge。RPC goroutine 永不等待 slot。
func (d *channelFanoutDispatcher) Enqueue(reqCtx context.Context, job channelFanoutJob) {
if d == nil || job.build == nil {
return
@ -162,14 +618,256 @@ func (d *channelFanoutDispatcher) Enqueue(reqCtx context.Context, job channelFan
d.r.runChannelFanoutJob(reqCtx, job)
return
}
shard := d.shards[d.shardIndex(job.channelID)]
select {
case shard <- job:
default:
d.dropped.Add(1)
d.log.Warn("channel fanout queue full, dropped realtime push (recovered via next pts gap / getChannelDifference)",
zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts))
d.enqueueMu.RLock()
if d.stopped.Load() {
d.enqueueMu.RUnlock()
return
}
shard := d.shards[d.shardIndex(job.channelID)]
queued := false
if d.reserveQueuedJob(job) {
queued = shard.enqueue(job)
if queued {
d.enqueueMu.RUnlock()
return
}
d.releaseQueuedJob(job)
}
d.dropped.Add(1)
if job.scope != channelFanoutMembers || job.pts <= 0 {
// 当前所有 enqueue 入口均为 members + durable pts若未来新增其它 scope必须先
// 定义其 overflow 恢复面,不能误把 viewer-only/no-pts 更新伪装成 channel nudge。
d.log.Error("channel fanout queue full for non-coalescible job; overflow contract violated",
zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts), zap.Int("scope", int(job.scope)))
d.enqueueMu.RUnlock()
return
}
channelID, pts := job.channelID, job.pts
// The payload closure and recipient snapshot are no longer needed after normal queue
// admission failed. Make them unreachable before applying overflow-cardinality backpressure;
// otherwise blocked producers would retain unbudgeted request bodies while waiting for one of
// the fixed mailbox slots. Inbound RPC concurrency remains the producer-count bound.
job.recipients = nil
job.prefetch = nil
job.build = nil
if !shard.enqueueOverflow(channelID, pts) {
// Every key-bearing in-memory structure is bounded. Once the shard mailbox has no distinct
// channel slot, do not add another queue and do not park this RPC worker. A generation bit is
// enough because channels.pts/channel_update_events are already the durable truth: the fixed
// recovery actor can enumerate all online channel ids and reconstruct the highest watermark.
d.requestRecoverySweep("overflow cardinality full")
d.log.Warn("channel fanout overflow cardinality exhausted; scheduled durable max-pts recovery sweep",
zap.Int64("channel_id", channelID), zap.Int("pts", pts))
d.enqueueMu.RUnlock()
return
}
d.log.Warn("channel fanout capacity exhausted, coalesced realtime payload into highest-pts overflow nudge",
zap.Int64("channel_id", channelID), zap.Int("pts", pts))
d.enqueueMu.RUnlock()
}
func (d *channelFanoutDispatcher) reserveQueuedJob(job channelFanoutJob) bool {
size := job.retainedBytes
if size < channelFanoutMinRetainedBytes {
size = channelFanoutMinRetainedBytes
}
d.budgetMu.Lock()
defer d.budgetMu.Unlock()
if d.queuedJobs >= d.maxQueuedJobs || size > d.maxQueuedBytes-d.queuedBytes {
return false
}
d.queuedJobs++
d.queuedBytes += size
return true
}
func (d *channelFanoutDispatcher) releaseQueuedJob(job channelFanoutJob) {
size := job.retainedBytes
if size < channelFanoutMinRetainedBytes {
size = channelFanoutMinRetainedBytes
}
d.budgetMu.Lock()
d.queuedJobs--
d.queuedBytes -= size
if d.queuedJobs < 0 || d.queuedBytes < 0 {
panic("channel fanout queue budget underflow")
}
d.budgetMu.Unlock()
}
func (d *channelFanoutDispatcher) queuedBudgetSnapshot() (jobs int, bytes int64) {
d.budgetMu.Lock()
defer d.budgetMu.Unlock()
return d.queuedJobs, d.queuedBytes
}
func (d *channelFanoutDispatcher) drainOneOverflow(shard *channelFanoutShard) (queued, blocked bool) {
return shard.tryQueueOverflow(d.offerNudge)
}
// offerNudge inserts one channel id into the bounded shared queue and stores its mutable highest
// pts in nudgePending. It never blocks. A same-channel update succeeds even when cardinality is
// full because it consumes no additional queue slot.
func (d *channelFanoutDispatcher) offerNudge(nudge channelFanoutNudge) bool {
if nudge.channelID == 0 || nudge.pts <= 0 {
return true
}
d.nudgeMu.Lock()
if current, exists := d.nudgePending[nudge.channelID]; exists {
if nudge.pts > current {
d.nudgePending[nudge.channelID] = nudge.pts
}
d.nudgeMu.Unlock()
return true
}
if len(d.nudgePending) >= d.nudgeLimit {
d.nudgeMu.Unlock()
return false
}
d.nudgePending[nudge.channelID] = nudge.pts
select {
case d.nudgeJobs <- nudge.channelID:
d.nudgeMu.Unlock()
return true
default:
// nudgeJobs has the same cardinality bound as nudgePending. This branch is reachable only
// while a test overrides one without the other or an invariant regresses; roll back rather
// than retain an unreachable map entry.
delete(d.nudgePending, nudge.channelID)
d.nudgeMu.Unlock()
return false
}
}
func (d *channelFanoutDispatcher) takeNudge(channelID int64) (channelFanoutNudge, bool) {
d.nudgeMu.Lock()
pts, ok := d.nudgePending[channelID]
if ok {
delete(d.nudgePending, channelID)
}
d.nudgeMu.Unlock()
return channelFanoutNudge{channelID: channelID, pts: pts}, ok
}
func (d *channelFanoutDispatcher) requestRecoverySweep(reason string) {
generation := d.recoveryGeneration.Add(1)
select {
case d.recoveryWake <- struct{}{}:
default:
}
d.log.Debug("channel fanout durable recovery sweep requested",
zap.Uint64("generation", generation), zap.String("reason", reason))
}
// runRecoverySweeps owns the only potentially waiting overflow admission path. Producers publish
// generations and return; this fixed actor reconstructs channel ids from the live membership index
// and watermarks from durable channels.pts. A generation is marked complete only after every page
// and every channel in that page has successfully entered its shard's barrier-preserving overflow
// mailbox. Errors retain the generation and retry with bounded backoff.
func (d *channelFanoutDispatcher) runRecoverySweeps(ctx context.Context) {
completed := d.recoveryCompleted.Load()
retryDelay := channelFanoutRecoveryRetryMin
var retryTimer *time.Timer
defer func() {
if retryTimer != nil {
retryTimer.Stop()
}
}()
for {
target := d.recoveryGeneration.Load()
if target <= completed {
select {
case <-ctx.Done():
return
case <-d.recoveryWake:
continue
}
}
if err := d.sweepOnlineChannelRecovery(ctx); err != nil {
if ctx.Err() != nil {
return
}
d.log.Warn("channel fanout durable recovery sweep failed; retaining generation",
zap.Uint64("generation", target), zap.Duration("retry_in", retryDelay), zap.Error(err))
if retryTimer == nil {
retryTimer = time.NewTimer(retryDelay)
} else {
retryTimer.Reset(retryDelay)
}
// recoveryGeneration already records every concurrent request. A wake may shorten the
// idle wait before a healthy sweep, but it must never bypass failure backoff: otherwise
// sustained saturation plus a persistent DB error retries at producer rate.
select {
case <-ctx.Done():
return
case <-retryTimer.C:
}
if retryDelay < channelFanoutRecoveryRetryMax {
retryDelay *= 2
if retryDelay > channelFanoutRecoveryRetryMax {
retryDelay = channelFanoutRecoveryRetryMax
}
}
continue
}
completed = target
d.recoveryCompleted.Store(completed)
retryDelay = channelFanoutRecoveryRetryMin
d.log.Info("channel fanout durable recovery sweep completed", zap.Uint64("generation", completed))
// If a producer saturated after its channel had already been visited, generation is now
// greater than completed and the next loop immediately performs a fresh full pass.
}
}
func (d *channelFanoutDispatcher) sweepOnlineChannelRecovery(ctx context.Context) error {
sessions, ok := d.r.deps.Sessions.(ChannelFanoutRecoverySessionProvider)
if !ok {
return fmt.Errorf("sessions dependency lacks online channel recovery enumeration")
}
channels, ok := d.r.deps.Channels.(ChannelFanoutRecoveryPtsProvider)
if !ok {
return fmt.Errorf("channels dependency lacks durable max pts lookup")
}
channelIDs := sessions.OnlineChannelIDsSnapshot()
for i, channelID := range channelIDs {
if channelID <= 0 || (i > 0 && channelID <= channelIDs[i-1]) {
return fmt.Errorf("online channel recovery snapshot is not strictly ascending: index=%d got=%d", i, channelID)
}
}
for start := 0; start < len(channelIDs); start += defaultChannelFanoutRecoverySweepPage {
end := start + defaultChannelFanoutRecoverySweepPage
if end > len(channelIDs) {
end = len(channelIDs)
}
page := channelIDs[start:end]
ptsByChannel, err := channels.MaxChannelPtsBatch(ctx, page)
if err != nil {
return fmt.Errorf("load durable max pts for online channel page [%d:%d]: %w", start, end, err)
}
for _, channelID := range page {
pts := ptsByChannel[channelID]
if pts > 0 {
shard := d.shards[d.shardIndex(channelID)]
if !shard.enqueueOverflowWait(ctx, channelID, pts, d.stopCh) {
if err := ctx.Err(); err != nil {
return err
}
return fmt.Errorf("dispatcher stopped while admitting recovery for channel %d", channelID)
}
}
}
}
return nil
}
// runChannelFanoutOverflowNudge 是 queue-full 的 nudge-only 降级路径。不能复用原 job 的
// origin exclude同一 channel 水位可能合并多个不同发起 session向全部在线成员发最高 pts
// nudge 是保守且幂等的,已追上 pts 的 TDesktop 会直接忽略。
func (r *Router) runChannelFanoutOverflowNudge(ctx context.Context, channelID int64, pts int) bool {
if r.deps.Sessions == nil || channelID == 0 || pts <= 0 {
return true
}
return r.nudgeBeyondCapChannelMembers(ctx, channelID, pts, nil)
}
// runChannelFanoutJob 执行一条 fan-out与同步 pushChannelUpdatesWithScope 等价,区别是
@ -179,7 +877,7 @@ func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob)
if r.deps.Sessions == nil || job.build == nil {
return
}
pushCtx := WithSessionID(WithAuthKeyID(ctx, job.originAuthKeyID), job.originSessionID)
pushCtx := WithSessionID(WithRawAuthKeyID(ctx, job.originAuthKeyID), job.originSessionID)
recipients := r.channelFanoutRecipients(ctx, job.scope, job.channelID, job.recipients)
// 预热跨 viewer 用户投影fan-out 模板化):在逐 viewer build 之前一次性算好每 recipient 的
// 投影并预热共享 cache使 build 只命中缓存、不再 O(viewer) 逐个 ForViewer。覆盖 recipients +
@ -278,6 +976,7 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
ownerIDs := channelMessageFanoutOwnerIDs(res, extraUserIDs)
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
@ -342,6 +1041,7 @@ func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUser
ownerIDs := channelEditMessageFanoutOwnerIDs(res)
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, nudgePts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
@ -358,6 +1058,7 @@ func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID,
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessagesFanoutOwnerIDs(results, extraUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, channelID, pts, recipients,
int64(len(results))*(64<<10),
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
@ -385,28 +1086,37 @@ func (r *Router) channelNudgeMaxTargets() int {
// getChannelDifference设计 §10.3)。走 pushUserUpdatesbest-effort、未就绪入 pending、非
// transient符合设计 §决策4 的 nudge 投递可靠性要求。SessionManager 未实现 ChannelNudgeProvider
// 时(测试/未装配)静默跳过,不影响完整 payload 投递。
func (r *Router) nudgeBeyondCapChannelMembers(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) {
func (r *Router) nudgeBeyondCapChannelMembers(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) bool {
provider, ok := r.deps.Sessions.(ChannelNudgeProvider)
if !ok || channelID == 0 || pts <= 0 {
return
return true
}
targets := provider.OnlineChannelMemberUserIDsExcluding(channelID, delivered, r.channelNudgeMaxTargets())
if len(targets) == 0 {
return
return true
}
date := int(r.clock.Now().Unix())
tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID}
tooLong.SetPts(pts)
updates := &tg.Updates{
Updates: []tg.UpdateClass{tooLong},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
for _, userID := range targets {
select {
case <-ctx.Done():
return false
default:
}
if userID == 0 {
continue
}
tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID}
tooLong.SetPts(pts)
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{tooLong},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
})
// The nudge is viewer-independent and immutable. Reuse the TL object across
// recipients; SessionManager encodes before enqueue and never mutates it.
r.pushUserUpdates(ctx, userID, updates)
}
return ctx.Err() == nil
}

File diff suppressed because it is too large Load diff

View file

@ -445,14 +445,13 @@ func (r *Router) onMessagesSetChatTheme(ctx context.Context, req *tg.MessagesSet
if err != nil {
return nil, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.SetChatTheme(ctx, userID, domain.SetPrivateChatThemeRequest{
OwnerUserID: userID,
Peer: peer,
Emoticon: emoticon,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
@ -578,6 +577,7 @@ func (r *Router) enqueueChannelWallpaperFanout(ctx context.Context, originUserID
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(sendRes, nil)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},

View file

@ -369,7 +369,11 @@ func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelI
authKeyID, _ = AuthKeyIDFrom(ctx)
excludeSessionID, _ = SessionIDFrom(ctx)
}
event, _, err := r.deps.Updates.RecordChannelState(ctx, authKeyID, userID, channelID, excludeSessionID)
excludeAuthKeyID := [8]byte{}
if excludeCurrent {
excludeAuthKeyID = rawAuthKeyIDForOrigin(ctx)
}
event, _, err := r.deps.Updates.RecordChannelState(ctx, authKeyID, userID, channelID, excludeAuthKeyID, excludeSessionID)
if err != nil {
return
}

View file

@ -264,7 +264,7 @@ func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, cha
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, sessionID)
recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return event
}
@ -300,7 +300,7 @@ func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read
StillUnreadCount: read.StillUnreadCount,
ChannelPts: read.Pts,
Changed: read.Changed,
}, sessionID)
}, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return domain.UpdateEvent{}, internalErr()
}
@ -673,6 +673,8 @@ func channelInvalidErr(err error) error {
return tgerr400("USER_ALREADY_PARTICIPANT")
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
return randomIDDuplicateErr()
default:
if seconds, ok := domain.SlowModeWaitSeconds(err); ok {
return tgerr.New(420, fmt.Sprintf("SLOWMODE_WAIT_%d", seconds))

View file

@ -42,7 +42,7 @@ func (r *Router) onChannelsToggleViewForumAsMessages(ctx context.Context, req *t
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordChannelViewForumAsMessages(ctx, authKeyID, userID, channelID, req.Enabled, sessionID)
event, _, err = r.deps.Updates.RecordChannelViewForumAsMessages(ctx, authKeyID, userID, channelID, req.Enabled, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return nil, internalErr()
}

View file

@ -206,6 +206,14 @@ func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewer
updates = append(updates, update)
}
}
if res.Duplicate && res.ReplayDeleteEvent != nil {
if update := tgChannelUpdate(viewerUserID, *res.ReplayDeleteEvent); update != nil {
updates = append(updates, update)
}
if res.ReplayDeleteEvent.Date > date {
date = res.ReplayDeleteEvent.Date
}
}
collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs)
collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs)
if date == 0 {

View file

@ -379,7 +379,7 @@ func (r *Router) recordChatlistFilterUpdate(ctx context.Context, userID int64, f
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, sessionID)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return internalErr()
}
@ -400,7 +400,7 @@ func (r *Router) chatlistFilterUpdates(ctx context.Context, userID int64, filter
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, sessionID)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return nil, internalErr()
}

View file

@ -984,10 +984,10 @@ func (r *Router) recordAcceptedContactTargetUpdates(ctx context.Context, userID,
return internalErr()
}
var zeroAuthKeyID [8]byte
if err := r.recordPeerSettingsForUser(ctx, zeroAuthKeyID, targetUserID, peer, settings, 0); err != nil {
if err := r.recordPeerSettingsForUser(ctx, zeroAuthKeyID, targetUserID, peer, settings, zeroAuthKeyID, 0); err != nil {
return internalErr()
}
if err := r.recordContactsResetForUser(ctx, zeroAuthKeyID, targetUserID, 0); err != nil {
if err := r.recordContactsResetForUser(ctx, zeroAuthKeyID, targetUserID, zeroAuthKeyID, 0); err != nil {
return internalErr()
}
peerUser := domain.User{ID: userID}
@ -1017,14 +1017,14 @@ func (r *Router) pushContactsReset(ctx context.Context, userID int64) {
func (r *Router) recordContactsReset(ctx context.Context, userID int64) error {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.recordContactsResetForUser(ctx, authKeyID, userID, sessionID)
return r.recordContactsResetForUser(ctx, authKeyID, userID, rawAuthKeyIDForOrigin(ctx), sessionID)
}
func (r *Router) recordContactsResetForUser(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) error {
func (r *Router) recordContactsResetForUser(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
event, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, excludeSessionID)
event, _, err := r.deps.Updates.RecordContactsReset(ctx, stateAuthKeyID, userID, excludeAuthKeyID, excludeSessionID)
if err == nil && excludeSessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
@ -1034,7 +1034,7 @@ func (r *Router) recordContactsResetForUser(ctx context.Context, authKeyID [8]by
func (r *Router) recordPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.recordPeerSettingsForUser(ctx, authKeyID, userID, peer, settings, sessionID)
return r.recordPeerSettingsForUser(ctx, authKeyID, userID, peer, settings, rawAuthKeyIDForOrigin(ctx), sessionID)
}
func (r *Router) recordPeerStoryBlocked(ctx context.Context, userID int64, peer domain.Peer, blocked bool) error {
@ -1043,18 +1043,18 @@ func (r *Router) recordPeerStoryBlocked(ctx context.Context, userID int64, peer
if r.deps.Updates == nil || userID == 0 {
return nil
}
event, _, err := r.deps.Updates.RecordPeerStoryBlocked(ctx, authKeyID, userID, peer, blocked, sessionID)
event, _, err := r.deps.Updates.RecordPeerStoryBlocked(ctx, authKeyID, userID, peer, blocked, rawAuthKeyIDForOrigin(ctx), sessionID)
if err == nil && sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
return err
}
func (r *Router) recordPeerSettingsForUser(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) error {
func (r *Router) recordPeerSettingsForUser(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, excludeSessionID)
event, _, err := r.deps.Updates.RecordPeerSettings(ctx, stateAuthKeyID, userID, peer, settings, excludeAuthKeyID, excludeSessionID)
if err == nil && excludeSessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}

View file

@ -16,8 +16,21 @@ const (
sessionIDKey
userIDKey
invokeWithoutUpdatesKey
inboundRPCBytesKey
)
func withInboundRPCBytes(ctx context.Context, n int) context.Context {
if n < 0 {
n = 0
}
return context.WithValue(ctx, inboundRPCBytesKey, n)
}
func inboundRPCBytesFrom(ctx context.Context) int {
v, _ := ctx.Value(inboundRPCBytesKey).(int)
return v
}
const currentClientLayer = 227
var androidSDKVersionRE = regexp.MustCompile(`\bsdk\s+\d+\b`)
@ -153,6 +166,16 @@ func AuthKeyIDFrom(ctx context.Context) ([8]byte, bool) {
return v, ok
}
// rawAuthKeyIDForOrigin 返回用于 update/outbox 当前 session 排除的物理 raw key。
// 单测/非 edge 调用若没有注入 raw key才回退业务 key生产 Router context 两者都有。
func rawAuthKeyIDForOrigin(ctx context.Context) [8]byte {
if id, ok := RawAuthKeyIDFrom(ctx); ok {
return id
}
id, _ := AuthKeyIDFrom(ctx)
return id
}
// WithSessionID 在 ctx 注入调用方的 MTProto session_id。
func WithSessionID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, sessionIDKey, id)

View file

@ -71,6 +71,7 @@ type ScopedSessionBinder interface {
UserIDResolvedForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (userID int64, resolved bool)
SetReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64, receives bool)
PushToSessionForAuthKey(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error
// excludeAuthKeyID is the physical/raw auth key, paired with session_id.
PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error)
}
@ -158,6 +159,21 @@ type ChannelNudgeProvider interface {
OnlineChannelMemberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64
}
// ChannelFanoutRecoverySessionProvider snapshots the process-local online joined-channel index in
// stable channel-id order. It is used only after every keyed fan-out mailbox is saturated. The
// fixed recovery actor accepts the temporary 8*C id slice so one sweep never repeatedly scans the
// SessionManager index or holds its global lock while sorting/database work runs.
type ChannelFanoutRecoverySessionProvider interface {
OnlineChannelIDsSnapshot() []int64
}
// ChannelFanoutRecoveryPtsProvider reloads the authoritative channel pts after in-memory fan-out
// saturation. Production channels.Service implements it through the channel store; keeping this
// separate from ChannelsService avoids burdening lightweight RPC fakes that never run the worker.
type ChannelFanoutRecoveryPtsProvider interface {
MaxChannelPtsBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error)
}
// RateLimiter 抽象 RPC 高频写操作限流。
type RateLimiter interface {
Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
@ -255,7 +271,7 @@ type UserPremiumStatusService interface {
// AccountService 抽象账号设置查询。
type AccountService interface {
SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error)
ChangePhone(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error)
ChangePhone(ctx context.Context, userID int64, authKeyID, originRawAuthKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error)
GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error)
GetPasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck) (domain.PrivatePasswordSettings, error)
UpdatePasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck, input domain.PasswordInputSettings) error
@ -270,10 +286,9 @@ type AccountService interface {
SendLoginEmailCode(ctx context.Context, userID int64, phone, phoneCodeHash, email string, setup bool) (string, int, error)
VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error)
SetLoginEmail(ctx context.Context, userID int64, email string) error
SetLoginEmailByPhone(ctx context.Context, phone, email string) error
LoginEmail(ctx context.Context, userID int64) (string, bool, error)
LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error)
ClearLoginEmailByPhone(ctx context.Context, phone string) error
ClearLoginEmail(ctx context.Context, userID int64) error
ResetPassword(ctx context.Context, userID int64) (domain.PasswordResetResult, error)
DeclinePasswordReset(ctx context.Context, userID int64) error
SaveMusic(ctx context.Context, userID int64, req domain.SaveMusicRequest) (bool, error)
@ -336,30 +351,30 @@ type UpdatesService interface {
ClearAuthKey(ctx context.Context, authKeyID [8]byte) error
RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error)
PublishNewMessage(ctx context.Context, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error)
RecordStory(ctx context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordStory(ctx context.Context, stateAuthKeyID [8]byte, userID int64, story domain.Story, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordStoryFanout(ctx context.Context, userID int64, story domain.Story) (domain.UpdateEvent, domain.UpdateState, error)
RecordReadStories(ctx context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordSentStoryReaction(ctx context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordNewStoryReaction(ctx context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordQuickReplyMutation(ctx context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelState(ctx context.Context, authKeyID [8]byte, userID, channelID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordSavedDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPinnedSavedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPeerStoryBlocked(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelDiscussionInbox(ctx context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDraftMessage(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordReadStories(ctx context.Context, stateAuthKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordSentStoryReaction(ctx context.Context, stateAuthKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordNewStoryReaction(ctx context.Context, stateAuthKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordQuickReplyMutation(ctx context.Context, stateAuthKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordReadHistory(ctx context.Context, stateAuthKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelState(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogPinned(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPinnedDialogs(ctx context.Context, stateAuthKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordSavedDialogPinned(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPinnedSavedDialogs(ctx context.Context, stateAuthKeyID [8]byte, userID int64, order []domain.Peer, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogUnreadMark(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPeerSettings(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordPeerStoryBlocked(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFilter(ctx context.Context, stateAuthKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFilterOrder(ctx context.Context, stateAuthKeyID [8]byte, userID int64, order []int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDialogFiltersReload(ctx context.Context, stateAuthKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordFolderPeers(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelAvailableMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelViewForumAsMessages(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordChannelDiscussionInbox(ctx context.Context, stateAuthKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// ContactsService 抽象通讯录查询。
@ -455,6 +470,13 @@ type MessagesService interface {
DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error)
}
// AlbumGroupService 是 MessagesService 的可选、生产必备能力sendMultiMedia 在
// 解析任何媒体或落第一条消息前,持久预留整批 random_id 的 grouped_id。
// 单独定义可避免让不触发 sendMultiMedia 的轻量测试替身实现无关方法。
type AlbumGroupService interface {
ReserveAlbumGroup(ctx context.Context, userID int64, req domain.AlbumGroupReservationRequest) (int64, error)
}
// StoriesService 抽象 story 读取、已读、观看与 reaction 状态。
type StoriesService interface {
CreateStory(ctx context.Context, userID int64, req domain.StoryCreateRequest) (domain.StoryCreateResult, error)

View file

@ -172,7 +172,11 @@ func TestMessagesGetPeerDialogsReturnsRequestedDialogsAndState(t *testing.T) {
func TestDialogSettingRPCsRecordDurableUpdates(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 9
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55)
rawAuthKeyID := [8]byte{9, 7}
ctx := WithRawAuthKeyID(
WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55),
rawAuthKeyID,
)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}
dialogs := &captureDialogs{}
@ -187,6 +191,9 @@ func TestDialogSettingRPCsRecordDurableUpdates(t *testing.T) {
if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned || updates.events[0].Peer != peer || !updates.events[0].Bool || updates.excludeSessionID != 55 {
t.Fatalf("pin event = %+v, want durable dialog_pinned", updates.events)
}
if updates.authKeyID != authKeyID || updates.excludeAuthKeyID != rawAuthKeyID {
t.Fatalf("durable update keys = state:%x exclude:%x, want business:%x raw:%x", updates.authKeyID, updates.excludeAuthKeyID, authKeyID, rawAuthKeyID)
}
if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}}); err != nil || !ok {
t.Fatalf("reorder pinned = %v, %v", ok, err)

View file

@ -88,7 +88,7 @@ func (r *Router) recordEncryptionEventBestEffort(ctx context.Context, chatID int
// 全部活跃密聊,并向对端推送 encryptedChatDiscarded在线+ 写 durable 事件(离线 getDifference
// 补偿。ownerUserID 是被销毁设备的所有者用于定位对端。best-effort失败仅记日志绝不阻断
// 登出/撤销。修复 P1此前 onAuthLogOut 等不级联 discard对端继续往死 auth_key 投递成静默死链
//(消息 acked=f / qts 永久积压,对端永看不到 discarded
// (消息 acked=f / qts 永久积压,对端永看不到 discarded
func (r *Router) discardSecretChatsForAuthKey(ctx context.Context, businessAuthKeyID, ownerUserID int64) {
if r.deps.SecretChats == nil || businessAuthKeyID == 0 || ownerUserID == 0 {
return

View file

@ -271,6 +271,8 @@ func chatForwardsRestrictedErr() error { return tgerr.New(400, "CHAT_FORWARDS_RE
func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVALID") }
func inputRequestTooLongErr() error { return tgerr.New(400, "INPUT_REQUEST_TOO_LONG") }
func persistentTimestampInvalidErr() error { return tgerr.New(400, "PERSISTENT_TIMESTAMP_INVALID") }
func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSING") }
@ -281,6 +283,10 @@ func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") }
// randomIDEmptyErr 表示发送消息缺少 random_id。
func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") }
// randomIDDuplicateErr 表示同一发送者重复使用 random_id但请求载荷与首次
// 成功发送不一致。Layer 227 为该错误定义的 code 是 500。
func randomIDDuplicateErr() error { return tgerr.New(500, "RANDOM_ID_DUPLICATE") }
// scheduleDateInvalidErr 表示当前阶段不支持定时消息。
func scheduleDateInvalidErr() error { return tgerr.New(400, "SCHEDULE_DATE_INVALID") }

View file

@ -53,7 +53,7 @@ func (r *Router) onFoldersEditPeerFolders(ctx context.Context, folderPeers []tg.
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordFolderPeers(ctx, authKeyID, userID, updates, sessionID)
event, _, err = r.deps.Updates.RecordFolderPeers(ctx, authKeyID, userID, updates, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return nil, internalErr()
}

View file

@ -57,7 +57,10 @@ func runIdleBackoffLoop(ctx context.Context, interval, maxIdleInterval time.Dura
case <-timer.C:
}
if dispatch(ctx) {
timer.Reset(backoff.ActiveDelay())
// 有积压时立即继续 draininterval 只用于空闲轮询。旧逻辑每个非空
// batch 也固定等待 base形成 batch/base 的人工吞吐上限。
_ = backoff.ActiveDelay()
timer.Reset(0)
continue
}
timer.Reset(backoff.IdleDelay())

View file

@ -1,6 +1,8 @@
package rpc
import (
"context"
"sync/atomic"
"testing"
"time"
)
@ -27,6 +29,35 @@ func TestIdleBackoffSequenceAndReset(t *testing.T) {
}
}
func TestIdleBackoffLoopDrainsActiveWorkImmediately(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
done := make(chan struct{})
started := time.Now()
go func() {
defer close(done)
runIdleBackoffLoop(ctx, time.Second, time.Second, func(context.Context) bool {
if calls.Add(1) < 4 {
return true
}
cancel()
return false
})
}()
select {
case <-done:
case <-time.After(300 * time.Millisecond):
t.Fatal("active drain waited for idle interval")
}
if got := calls.Load(); got != 4 {
t.Fatalf("dispatch calls = %d, want 4 consecutive active drains", got)
}
if elapsed := time.Since(started); elapsed >= 300*time.Millisecond {
t.Fatalf("active drain elapsed = %v, want no 1s base delay", elapsed)
}
}
func TestIdleBackoffSanitizesMaxBelowBase(t *testing.T) {
backoff := newIdleBackoff(2*time.Second, time.Second)
if got := backoff.IdleDelay(); got != 2*time.Second {

View file

@ -0,0 +1,84 @@
package rpc
import (
"crypto/sha256"
"fmt"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// rpcRequestFingerprint 在任何自动实体补全、链接预览解析或上传媒体落库之前,
// 对客户端原始 TL request 取稳定指纹。这样 lost-response 重放不会因服务端派生
// photo/document id、pending webpage 状态等变化而被误判为另一条消息。
func rpcRequestFingerprint(req bin.Encoder) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("fingerprint rpc request: nil request")
}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
return nil, fmt.Errorf("fingerprint rpc request: %w", err)
}
sum := sha256.Sum256(b.Raw())
return sum[:], nil
}
// sendMessageIdempotencyFingerprint fingerprints only the durable message intent.
// clear_draft/background/update_stickersets_order are one-shot client-side delivery
// hints: after a lost response DrKLO/TDesktop may legitimately retry the same
// random_id without them. They must not turn an exact send replay into
// RANDOM_ID_DUPLICATE.
func sendMessageIdempotencyFingerprint(req *tg.MessagesSendMessageRequest) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("fingerprint messages.sendMessage: nil request")
}
clone := *req
clone.Flags = 0
clone.ClearDraft = false
clone.Background = false
clone.UpdateStickersetsOrder = false
return rpcRequestFingerprint(&clone)
}
func sendMediaIdempotencyFingerprint(req *tg.MessagesSendMediaRequest) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("fingerprint messages.sendMedia: nil request")
}
clone := *req
clone.Flags = 0
clone.ClearDraft = false
clone.Background = false
clone.UpdateStickersetsOrder = false
return rpcRequestFingerprint(&clone)
}
// sendMultiMediaItemIdempotencyFingerprint deliberately reduces a batch to one
// InputSingleMedia. A retry containing only the failed subset therefore produces
// the same fingerprint for every surviving random_id as the original batch.
func sendMultiMediaItemIdempotencyFingerprint(req *tg.MessagesSendMultiMediaRequest, item tg.InputSingleMedia) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("fingerprint messages.sendMultiMedia item: nil request")
}
clone := *req
clone.Flags = 0
clone.ClearDraft = false
clone.Background = false
clone.UpdateStickersetsOrder = false
clone.MultiMedia = []tg.InputSingleMedia{item}
return rpcRequestFingerprint(&clone)
}
// forwardMessagesItemIdempotencyFingerprint makes the source message id and its
// paired random_id the unit of idempotency. Hashing the full ID/RandomID vectors
// incorrectly rejects a legal retry that contains only a failed subset.
func forwardMessagesItemIdempotencyFingerprint(req *tg.MessagesForwardMessagesRequest, messageID int, randomID int64) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("fingerprint messages.forwardMessages item: nil request")
}
clone := *req
clone.Flags = 0
clone.Background = false
clone.ID = []int{messageID}
clone.RandomID = []int64{randomID}
return rpcRequestFingerprint(&clone)
}

View file

@ -0,0 +1,315 @@
package rpc
import (
"bytes"
"context"
"fmt"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestRPCRequestFingerprintStableAndPayloadSensitive(t *testing.T) {
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: 1002, AccessHash: 22},
Message: "hello",
RandomID: 991,
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 5}},
}
first, err := rpcRequestFingerprint(req)
if err != nil {
t.Fatalf("first fingerprint: %v", err)
}
second, err := rpcRequestFingerprint(req)
if err != nil {
t.Fatalf("second fingerprint: %v", err)
}
if len(first) != 32 || !bytes.Equal(first, second) {
t.Fatalf("fingerprints = %x / %x, want stable SHA-256", first, second)
}
req.Message = "changed"
changed, err := rpcRequestFingerprint(req)
if err != nil {
t.Fatalf("changed fingerprint: %v", err)
}
if bytes.Equal(first, changed) {
t.Fatalf("changed payload fingerprint = %x, want different from %x", changed, first)
}
}
func TestSendMessageFingerprintIgnoresRetryOnlyHints(t *testing.T) {
first := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: 1002, AccessHash: 22},
Message: "hello",
RandomID: 991,
ClearDraft: true,
Background: true,
UpdateStickersetsOrder: true,
}
retry := *first
retry.Flags.Set(31) // stale decoded flags must not leak into the canonical intent.
retry.ClearDraft = false
retry.Background = false
retry.UpdateStickersetsOrder = false
a, err := sendMessageIdempotencyFingerprint(first)
if err != nil {
t.Fatalf("first fingerprint: %v", err)
}
b, err := sendMessageIdempotencyFingerprint(&retry)
if err != nil {
t.Fatalf("retry fingerprint: %v", err)
}
if !bytes.Equal(a, b) {
t.Fatalf("retry-only flags changed fingerprint: %x != %x", a, b)
}
retry.Message = "different"
c, err := sendMessageIdempotencyFingerprint(&retry)
if err != nil {
t.Fatalf("changed fingerprint: %v", err)
}
if bytes.Equal(a, c) {
t.Fatal("durable message change did not change fingerprint")
}
}
func TestSendMultiMediaFingerprintIsPerItemAndSubsetStable(t *testing.T) {
item1 := tg.InputSingleMedia{Media: &tg.InputMediaEmpty{}, RandomID: 101, Message: "one"}
item2 := tg.InputSingleMedia{Media: &tg.InputMediaEmpty{}, RandomID: 102, Message: "two"}
full := &tg.MessagesSendMultiMediaRequest{
Peer: &tg.InputPeerUser{UserID: 1002, AccessHash: 22},
ClearDraft: true,
Background: true,
MultiMedia: []tg.InputSingleMedia{item1, item2},
}
subset := &tg.MessagesSendMultiMediaRequest{
Peer: full.Peer,
MultiMedia: []tg.InputSingleMedia{item2},
}
fromFull, err := sendMultiMediaItemIdempotencyFingerprint(full, item2)
if err != nil {
t.Fatalf("full item fingerprint: %v", err)
}
fromSubset, err := sendMultiMediaItemIdempotencyFingerprint(subset, item2)
if err != nil {
t.Fatalf("subset item fingerprint: %v", err)
}
if !bytes.Equal(fromFull, fromSubset) {
t.Fatalf("subset retry fingerprint = %x, want %x", fromSubset, fromFull)
}
changed := item2
changed.Message = "changed"
different, err := sendMultiMediaItemIdempotencyFingerprint(subset, changed)
if err != nil {
t.Fatalf("changed item fingerprint: %v", err)
}
if bytes.Equal(fromFull, different) {
t.Fatal("changed album item reused the original fingerprint")
}
}
func TestForwardFingerprintIsPerItemAndSubsetStable(t *testing.T) {
full := &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerUser{UserID: 1002, AccessHash: 22},
ID: []int{41, 42},
RandomID: []int64{201, 202},
ToPeer: &tg.InputPeerUser{UserID: 1003, AccessHash: 33},
Background: true,
}
subset := *full
subset.Flags = 0
subset.Background = false
subset.ID = []int{42}
subset.RandomID = []int64{202}
fromFull, err := forwardMessagesItemIdempotencyFingerprint(full, 42, 202)
if err != nil {
t.Fatalf("full item fingerprint: %v", err)
}
fromSubset, err := forwardMessagesItemIdempotencyFingerprint(&subset, 42, 202)
if err != nil {
t.Fatalf("subset item fingerprint: %v", err)
}
if !bytes.Equal(fromFull, fromSubset) {
t.Fatalf("subset retry fingerprint = %x, want %x", fromSubset, fromFull)
}
different, err := forwardMessagesItemIdempotencyFingerprint(&subset, 41, 202)
if err != nil {
t.Fatalf("changed source fingerprint: %v", err)
}
if bytes.Equal(fromFull, different) {
t.Fatal("different source message reused the original fingerprint")
}
}
func TestMessageSendErrMapsRandomIDConflict(t *testing.T) {
err := messageSendErr(fmt.Errorf("wrapped store error: %w", domain.ErrMessageRandomIDDuplicate))
if !tgerr.Is(err, "RANDOM_ID_DUPLICATE") || !tgerr.IsCode(err, 500) {
t.Fatalf("messageSendErr = %v, want 500 RANDOM_ID_DUPLICATE", err)
}
}
func TestMessageForwardErrMapsRandomIDConflict(t *testing.T) {
err := messageForwardErr(fmt.Errorf("wrapped store error: %w", domain.ErrMessageRandomIDDuplicate))
if !tgerr.Is(err, "RANDOM_ID_DUPLICATE") || !tgerr.IsCode(err, 500) {
t.Fatalf("messageForwardErr = %v, want 500 RANDOM_ID_DUPLICATE", err)
}
}
func TestPrivateSendDuplicateResponseIncludesAndroidConfirmationSnapshot(t *testing.T) {
res := domain.SendPrivateTextResult{
Duplicate: true,
SenderMessage: domain.Message{
ID: 41, UID: 51, RandomID: 9911, OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000000, Out: true, Body: "confirmed", Pts: 7,
},
SenderEvent: domain.UpdateEvent{Pts: 7, PtsCount: 1, Date: 1700000000},
}
updates := tgPrivateSendResultUpdates(res, 9911, true, nil, nil)
if len(updates.Updates) != 2 {
t.Fatalf("duplicate updates = %#v, want mapping + new message", updates.Updates)
}
mapping, ok := updates.Updates[0].(*tg.UpdateMessageID)
if !ok || mapping.ID != 41 || mapping.RandomID != 9911 {
t.Fatalf("duplicate mapping = %#v, want id/random_id 41/9911", updates.Updates[0])
}
confirmed, ok := updates.Updates[1].(*tg.UpdateNewMessage)
if !ok || confirmed.Pts != 7 || confirmed.PtsCount != 1 {
t.Fatalf("duplicate confirmation = %#v, want UpdateNewMessage pts 7/1", updates.Updates[1])
}
msg, ok := confirmed.Message.(*tg.Message)
if !ok || msg.ID != 41 || msg.Message != "confirmed" {
t.Fatalf("duplicate confirmation message = %#v, want sender snapshot", confirmed.Message)
}
}
func TestPrivateSendDeletedDuplicateConfirmsThenConvergesWithDurableDelete(t *testing.T) {
deleteEvent := domain.UpdateEvent{UserID: 1001, Type: domain.UpdateEventDeleteMessages, Pts: 9, PtsCount: 1, Date: 1700000002, MessageIDs: []int{41}}
res := domain.SendPrivateTextResult{
Duplicate: true,
SenderMessage: domain.Message{
ID: 41, UID: 51, RandomID: 9911, OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000000, Out: true, Body: "original", Pts: 7,
},
SenderEvent: domain.UpdateEvent{Pts: 7, PtsCount: 1, Date: 1700000000},
ReplayDeleteEvent: &deleteEvent,
}
updates := tgPrivateSendResultUpdates(res, 9911, true, nil, nil)
if len(updates.Updates) != 3 {
t.Fatalf("deleted duplicate updates = %#v, want mapping + new + delete", updates.Updates)
}
if _, ok := updates.Updates[1].(*tg.UpdateNewMessage); !ok {
t.Fatalf("deleted duplicate confirmation = %#v, want UpdateNewMessage", updates.Updates[1])
}
deleted, ok := updates.Updates[2].(*tg.UpdateDeleteMessages)
if !ok || deleted.Pts != 9 || deleted.PtsCount != 1 || len(deleted.Messages) != 1 || deleted.Messages[0] != 41 {
t.Fatalf("deleted duplicate convergence = %#v, want real delete event", updates.Updates[2])
}
}
func TestForwardDeletedDuplicateIncludesMessageAndDelete(t *testing.T) {
deleteEvent := &domain.UpdateEvent{UserID: 1001, Type: domain.UpdateEventDeleteMessages, Pts: 12, PtsCount: 1, Date: 1700000012, MessageIDs: []int{61}}
updates := tgForwardMessagesUpdates(domain.ForwardPrivateMessagesResult{
SenderMessages: []domain.Message{{
ID: 61, UID: 71, RandomID: 8811, OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000010, Out: true, Body: "forwarded", Pts: 10,
}},
SenderEvents: []domain.UpdateEvent{{Pts: 10, PtsCount: 1, Date: 1700000010}},
Duplicates: []bool{true},
ReplayDeleteEvents: []*domain.UpdateEvent{deleteEvent},
}, []int64{8811}, nil, nil)
if len(updates.Updates) != 3 {
t.Fatalf("forward duplicate updates = %#v, want mapping + new + delete", updates.Updates)
}
if _, ok := updates.Updates[1].(*tg.UpdateNewMessage); !ok {
t.Fatalf("forward duplicate confirmation = %#v, want UpdateNewMessage", updates.Updates[1])
}
if deleted, ok := updates.Updates[2].(*tg.UpdateDeleteMessages); !ok || len(deleted.Messages) != 1 || deleted.Messages[0] != 61 || deleted.Pts != 12 {
t.Fatalf("forward duplicate delete = %#v, want durable delete", updates.Updates[2])
}
}
func TestChannelDeletedDuplicateEchoIncludesMessageAndDelete(t *testing.T) {
router := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
deleteEvent := &domain.ChannelUpdateEvent{ChannelID: 2001, Type: domain.ChannelUpdateDeleteMessages, Pts: 12, PtsCount: 1, Date: 1700000012, MessageIDs: []int{61}}
msg := domain.ChannelMessage{
ChannelID: 2001, ID: 61, RandomID: 8811, SenderUserID: 1001,
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000010, Body: "channel", Pts: 10,
}
updates := router.channelMessagesUpdatesWithPeerCache(context.Background(), 1001, []domain.SendChannelMessageResult{{
Channel: domain.Channel{ID: 2001, AccessHash: 22, Title: "group", Megagroup: true, Date: 1700000000},
Message: msg,
Event: domain.ChannelUpdateEvent{
ChannelID: 2001, Type: domain.ChannelUpdateNewMessage, Pts: 10, PtsCount: 1, Date: 1700000010, Message: msg,
},
Duplicate: true,
ReplayDeleteEvent: deleteEvent,
}}, []int64{8811}, true, nil, newViewerPeerCache(router))
if len(updates.Updates) != 3 {
t.Fatalf("channel duplicate updates = %#v, want mapping + new + delete", updates.Updates)
}
if _, ok := updates.Updates[1].(*tg.UpdateNewChannelMessage); !ok {
t.Fatalf("channel duplicate confirmation = %#v, want UpdateNewChannelMessage", updates.Updates[1])
}
if deleted, ok := updates.Updates[2].(*tg.UpdateDeleteChannelMessages); !ok || deleted.ChannelID != 2001 || len(deleted.Messages) != 1 || deleted.Messages[0] != 61 || deleted.Pts != 12 {
t.Fatalf("channel duplicate delete = %#v, want durable channel delete", updates.Updates[2])
}
}
func TestPrivateSendRecordsRawOriginAuthKey(t *testing.T) {
const (
senderID = int64(1001)
recipientID = int64(1002)
)
raw := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
business := [8]byte{8, 7, 6, 5, 4, 3, 2, 1}
messages := &captureMessages{}
router := New(Config{}, Deps{
Messages: messages,
Users: mapUsersService{users: map[int64]domain.User{
senderID: {ID: senderID, FirstName: "Sender"},
recipientID: {ID: recipientID, FirstName: "Recipient"},
}},
}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(
WithRawAuthKeyID(
WithAuthKeyID(
WithUserID(context.Background(), senderID),
business,
),
raw,
),
77,
)
if _, err := router.onMessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: recipientID},
Message: "raw origin",
RandomID: 992,
}); err != nil {
t.Fatalf("send message: %v", err)
}
if messages.sendReq.OriginAuthKeyID != raw || messages.sendReq.OriginAuthKeyID == business {
t.Fatalf("origin auth key = %x, want raw %x (business %x)", messages.sendReq.OriginAuthKeyID, raw, business)
}
if messages.sendReq.OriginSessionID != 77 {
t.Fatalf("origin session = %d, want 77", messages.sendReq.OriginSessionID)
}
}

View file

@ -0,0 +1,454 @@
package rpc
import (
"context"
"reflect"
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type replayPreflightMessages struct {
*captureMessages
replays map[int64]domain.SendPrivateTextResult
lookupRequests []domain.PrivateSendReplayRequest
sendRequests []domain.SendPrivateTextRequest
reserveRequests []domain.AlbumGroupReservationRequest
reservedGroupedID int64
forceSendDuplicate bool
}
func newReplayPreflightMessages() *replayPreflightMessages {
return &replayPreflightMessages{
captureMessages: &captureMessages{},
replays: make(map[int64]domain.SendPrivateTextResult),
reservedGroupedID: 81001,
}
}
func (s *replayPreflightMessages) LookupPrivateSendReplay(_ context.Context, _ int64, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
s.lookupRequests = append(s.lookupRequests, req)
res, found := s.replays[req.RandomID]
if found {
res.Duplicate = true
}
return res, found, nil
}
func (s *replayPreflightMessages) SendPrivateText(_ context.Context, _ int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
s.sendRequests = append(s.sendRequests, req)
res := privateReplayFixture(req.SenderUserID, req.RecipientUserID, req.RandomID, 100+len(s.sendRequests), req.GroupedID)
res.Duplicate = s.forceSendDuplicate
s.replays[req.RandomID] = res
return res, nil
}
func (s *replayPreflightMessages) ReserveAlbumGroup(_ context.Context, _ int64, req domain.AlbumGroupReservationRequest) (int64, error) {
s.reserveRequests = append(s.reserveRequests, req)
return s.reservedGroupedID, nil
}
func privateReplayFixture(senderID, recipientID, randomID int64, messageID int, groupedID int64) domain.SendPrivateTextResult {
msg := domain.Message{
ID: messageID,
UID: int64(messageID) + 1000,
RandomID: randomID,
OwnerUserID: senderID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
Date: 1700000000 + messageID,
Out: true,
Body: "committed",
GroupedID: groupedID,
Pts: messageID,
}
event := domain.UpdateEvent{
UserID: senderID,
Type: domain.UpdateEventNewMessage,
Pts: messageID,
PtsCount: 1,
Date: msg.Date,
Message: msg,
}
return domain.SendPrivateTextResult{
SenderMessage: msg,
SenderEvent: event,
}
}
type replayCountingDialogs struct {
*captureDialogs
deleteDraftCalls int
}
func (s *replayCountingDialogs) DeleteDraft(_ context.Context, _ int64, peer domain.Peer, topMessageID int) (bool, error) {
s.deleteDraftCalls++
s.deletedDraft.peer = peer
s.deletedDraft.topMessageID = topMessageID
return true, nil
}
type replaySelectiveFiles struct {
*fakeFiles
getPhotoIDs []int64
}
func (f *replaySelectiveFiles) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
f.getPhotoIDs = append(f.getPhotoIDs, id)
return f.fakeFiles.GetPhoto(ctx, id)
}
func TestSendMessageExactReplayPrecedesSaturatedRateLimiter(t *testing.T) {
const userID = int64(1001)
messages := newReplayPreflightMessages()
messages.replays[7001] = privateReplayFixture(userID, userID, 7001, 71, 0)
limiter := &captureRateLimiter{block: true, retryAfter: 30}
dialogs := &replayCountingDialogs{captureDialogs: &captureDialogs{}}
r := New(Config{SendRateLimit: 1, SendRateWindow: time.Minute}, Deps{
Messages: messages,
Dialogs: dialogs,
Limiter: limiter,
}, zaptest.NewLogger(t), clock.System)
updates, err := r.onMessagesSendMessage(WithUserID(context.Background(), userID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "already committed",
RandomID: 7001,
ClearDraft: true,
})
if err != nil {
t.Fatalf("exact replay: %v", err)
}
if _, ok := updates.(*tg.Updates); !ok {
t.Fatalf("updates = %T, want *tg.Updates", updates)
}
if len(limiter.calls) != 0 {
t.Fatalf("limiter calls = %+v, want none for exact replay", limiter.calls)
}
if len(messages.sendRequests) != 0 {
t.Fatalf("send requests = %d, want 0", len(messages.sendRequests))
}
if dialogs.deleteDraftCalls != 0 {
t.Fatalf("draft deletes = %d, want 0 for duplicate", dialogs.deleteDraftCalls)
}
}
func TestSendMessageConcurrentReplayRaceDoesNotClearDraft(t *testing.T) {
const userID = int64(1001)
messages := newReplayPreflightMessages()
// The read-only preflight misses, then the atomic store fence observes that another request
// committed the same random_id first and returns Duplicate=true.
messages.forceSendDuplicate = true
dialogs := &replayCountingDialogs{captureDialogs: &captureDialogs{}}
r := New(Config{}, Deps{Messages: messages, Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), userID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "concurrent exact replay",
RandomID: 7002,
ClearDraft: true,
}); err != nil {
t.Fatalf("concurrent replay race: %v", err)
}
if len(messages.lookupRequests) != 1 || len(messages.sendRequests) != 1 {
t.Fatalf("lookup/send calls = %d/%d, want preflight miss then one atomic send", len(messages.lookupRequests), len(messages.sendRequests))
}
if dialogs.deleteDraftCalls != 0 {
t.Fatalf("draft deletes = %d, want 0 when store race returns duplicate", dialogs.deleteDraftCalls)
}
}
func TestSendMediaExactReplayPrecedesMediaResolvers(t *testing.T) {
const userID = int64(1001)
tests := []struct {
name string
media tg.InputMediaClass
}{
{
name: "uploaded photo",
media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 11, Parts: 1, Name: "gone.jpg"}},
},
{
name: "referenced photo",
media: &tg.InputMediaPhoto{ID: &tg.InputPhoto{ID: 22, AccessHash: 220}},
},
{
name: "poll",
media: &tg.InputMediaPoll{Poll: tg.Poll{
Question: tg.TextWithEntities{Text: "question?", Entities: []tg.MessageEntityClass{}},
Answers: []tg.PollAnswerClass{
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "yes", Entities: []tg.MessageEntityClass{}}, Option: []byte{0}},
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "no", Entities: []tg.MessageEntityClass{}}, Option: []byte{1}},
},
}},
},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
randomID := int64(7100 + i)
messages := newReplayPreflightMessages()
messages.replays[randomID] = privateReplayFixture(userID, userID, randomID, 80+i, 0)
limiter := &captureRateLimiter{block: true, retryAfter: 30}
r := New(Config{SendRateLimit: 1, SendRateWindow: time.Minute}, Deps{
Messages: messages,
Limiter: limiter,
// Files and Polls are intentionally nil. Reaching any resolver would fail.
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerSelf{},
Media: tc.media,
Message: "already committed",
RandomID: randomID,
}
if fingerprint, err := sendMediaIdempotencyFingerprint(req); err != nil || len(fingerprint) != 32 {
t.Fatalf("fingerprint len=%d err=%v, want SHA-256", len(fingerprint), err)
}
if _, err := r.onMessagesSendMedia(WithUserID(context.Background(), userID), req); err != nil {
t.Fatalf("exact replay: %v", err)
}
if len(limiter.calls) != 0 {
t.Fatalf("limiter calls = %+v, want none", limiter.calls)
}
if len(messages.sendRequests) != 0 || len(messages.lookupRequests) != 1 {
t.Fatalf("lookup=%d send=%d, want 1/0", len(messages.lookupRequests), len(messages.sendRequests))
}
})
}
}
func TestSendMultiMediaMixedReplayChargesAndResolvesOnlyAbsentItems(t *testing.T) {
const userID = int64(1001)
messages := newReplayPreflightMessages()
messages.replays[7201] = privateReplayFixture(userID, userID, 7201, 91, messages.reservedGroupedID)
limiter := &captureRateLimiter{}
dialogs := &replayCountingDialogs{captureDialogs: &captureDialogs{}}
files := &replaySelectiveFiles{fakeFiles: &fakeFiles{photos: map[int64]domain.Photo{
222: {ID: 222, AccessHash: 2220, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 100, H: 100}}},
}}}
r := New(Config{SendRateLimit: 10, SendRateWindow: time.Minute}, Deps{
Messages: messages,
Dialogs: dialogs,
Files: files,
Limiter: limiter,
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMultiMediaRequest{
Peer: &tg.InputPeerSelf{},
ClearDraft: true,
MultiMedia: []tg.InputSingleMedia{
{Media: &tg.InputMediaPhoto{ID: &tg.InputPhoto{ID: 111, AccessHash: 1110}}, RandomID: 7201, Message: "duplicate"},
{Media: &tg.InputMediaPhoto{ID: &tg.InputPhoto{ID: 222, AccessHash: 2220}}, RandomID: 7202, Message: "new"},
},
}
if _, err := r.onMessagesSendMultiMedia(WithUserID(context.Background(), userID), req); err != nil {
t.Fatalf("mixed sendMultiMedia: %v", err)
}
if len(limiter.calls) != 1 || limiter.calls[0].cost != 1 {
t.Fatalf("limiter calls = %+v, want one absent-item cost", limiter.calls)
}
if !reflect.DeepEqual(files.getPhotoIDs, []int64{222}) {
t.Fatalf("resolved photo IDs = %v, want only absent item 222", files.getPhotoIDs)
}
if len(messages.sendRequests) != 1 || messages.sendRequests[0].RandomID != 7202 {
t.Fatalf("send requests = %+v, want only random_id 7202", messages.sendRequests)
}
if len(messages.reserveRequests) != 1 {
t.Fatalf("album reservations = %d, want 1", len(messages.reserveRequests))
}
if dialogs.deleteDraftCalls != 1 {
t.Fatalf("draft deletes = %d, want exactly one from first genuinely-new item", dialogs.deleteDraftCalls)
}
limiterCalls := len(limiter.calls)
resolved := append([]int64(nil), files.getPhotoIDs...)
reservations := len(messages.reserveRequests)
if _, err := r.onMessagesSendMultiMedia(WithUserID(context.Background(), userID), req); err != nil {
t.Fatalf("full duplicate sendMultiMedia: %v", err)
}
if len(limiter.calls) != limiterCalls {
t.Fatalf("full duplicate added limiter calls: before=%d after=%d", limiterCalls, len(limiter.calls))
}
if !reflect.DeepEqual(files.getPhotoIDs, resolved) {
t.Fatalf("full duplicate resolved media: before=%v after=%v", resolved, files.getPhotoIDs)
}
if len(messages.reserveRequests) != reservations {
t.Fatalf("full duplicate reservations: before=%d after=%d", reservations, len(messages.reserveRequests))
}
if dialogs.deleteDraftCalls != 1 {
t.Fatalf("full duplicate cleared draft again: calls=%d", dialogs.deleteDraftCalls)
}
}
func TestForwardReplayPreflightSkipsCommittedSourcesAndLoadsOnlyAbsentIDs(t *testing.T) {
const userID = int64(1001)
t.Run("full duplicate tolerates deleted sources", func(t *testing.T) {
messages := newReplayPreflightMessages()
messages.replays[7301] = privateReplayFixture(userID, userID, 7301, 101, 0)
messages.replays[7302] = privateReplayFixture(userID, userID, 7302, 102, 0)
limiter := &captureRateLimiter{block: true, retryAfter: 30}
r := New(Config{SendRateLimit: 1, SendRateWindow: time.Minute}, Deps{Messages: messages, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
if _, err := r.onMessagesForwardMessages(WithUserID(context.Background(), userID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerEmpty{},
ToPeer: &tg.InputPeerSelf{},
ID: []int{41, 42},
RandomID: []int64{7301, 7302},
}); err != nil {
t.Fatalf("full duplicate forward with deleted sources: %v", err)
}
if messages.getMessagesCalls != 0 {
t.Fatalf("GetMessages calls = %d, want 0", messages.getMessagesCalls)
}
if len(limiter.calls) != 0 || len(messages.sendRequests) != 0 {
t.Fatalf("limiter=%v sends=%d, want no side effects", limiter.calls, len(messages.sendRequests))
}
})
t.Run("mixed loads absent IDs only", func(t *testing.T) {
messages := newReplayPreflightMessages()
messages.replays[7311] = privateReplayFixture(userID, userID, 7311, 111, 0)
messages.list = domain.MessageList{Messages: []domain.Message{{
ID: 52,
OwnerUserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2002},
Date: 1700000100,
Body: "still available",
}}}
limiter := &captureRateLimiter{}
r := New(Config{SendRateLimit: 10, SendRateWindow: time.Minute}, Deps{Messages: messages, Limiter: limiter}, zaptest.NewLogger(t), clock.System)
if _, err := r.onMessagesForwardMessages(WithUserID(context.Background(), userID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerEmpty{},
ToPeer: &tg.InputPeerSelf{},
ID: []int{51, 52},
RandomID: []int64{7311, 7312},
}); err != nil {
t.Fatalf("mixed forward: %v", err)
}
if messages.getMessagesCalls != 1 || !reflect.DeepEqual(messages.getMessagesIDs, [][]int{{52}}) {
t.Fatalf("GetMessages calls=%d ids=%v, want one [52]", messages.getMessagesCalls, messages.getMessagesIDs)
}
if len(limiter.calls) != 1 || limiter.calls[0].cost != 1 {
t.Fatalf("limiter calls = %+v, want cost 1", limiter.calls)
}
if len(messages.sendRequests) != 1 || messages.sendRequests[0].RandomID != 7312 {
t.Fatalf("send requests = %+v, want only random_id 7312", messages.sendRequests)
}
})
}
func TestChannelAndMonoforumExactReplayPrecedesLimiterAndCurrentPermission(t *testing.T) {
t.Run("channel replay survives sender ban", func(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "15550007001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 12, Phone: "15550007002", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channels := appchannels.NewService(memory.NewChannelStore())
created, err := channels.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Replay Group", MemberUserIDs: []int64{member.ID}, Date: 100,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
limiter := &captureRateLimiter{}
r := New(Config{SendRateLimit: 10, SendRateWindow: time.Minute}, Deps{
Users: appusers.NewService(users),
Channels: channels,
Limiter: limiter,
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
Message: "committed before ban",
RandomID: 7401,
}
memberCtx := WithUserID(ctx, member.ID)
if _, err := r.onMessagesSendMessage(memberCtx, req); err != nil {
t.Fatalf("first channel send: %v", err)
}
callsBeforeReplay := len(limiter.calls)
if _, err := channels.EditBanned(ctx, owner.ID, domain.EditChannelBannedRequest{
ChannelID: created.Channel.ID,
Participant: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID},
BannedRights: domain.ChannelBannedRights{
ViewMessages: true,
UntilDate: 1000,
},
Date: 101,
}); err != nil {
t.Fatalf("ban member: %v", err)
}
limiter.block = true
if _, err := r.onMessagesSendMessage(memberCtx, req); err != nil {
t.Fatalf("channel exact replay after ban: %v", err)
}
if len(limiter.calls) != callsBeforeReplay {
t.Fatalf("replay limiter calls: before=%d after=%d", callsBeforeReplay, len(limiter.calls))
}
})
t.Run("monoforum replay survives direct-message disable", func(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 21, Phone: "15550007101", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "15550007102", FirstName: "Subscriber"})
if err != nil {
t.Fatalf("create subscriber: %v", err)
}
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore)
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Replay DM", Broadcast: true, Date: 200})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatalf("enable direct messages: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
limiter := &captureRateLimiter{}
r := New(Config{SendRateLimit: 10, SendRateWindow: time.Minute}, Deps{
Users: appusers.NewService(users),
Channels: channels,
Limiter: limiter,
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: monoID},
Message: "committed before disable",
RandomID: 7411,
}
req.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: subscriber.ID}})
subscriberCtx := WithUserID(ctx, subscriber.ID)
if _, err := r.onMessagesSendMessage(subscriberCtx, req); err != nil {
t.Fatalf("first monoforum send: %v", err)
}
callsBeforeReplay := len(limiter.calls)
if _, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, false); err != nil {
t.Fatalf("disable direct messages: %v", err)
}
limiter.block = true
if _, err := r.onMessagesSendMessage(subscriberCtx, req); err != nil {
t.Fatalf("monoforum exact replay after disable: %v", err)
}
if len(limiter.calls) != callsBeforeReplay {
t.Fatalf("replay limiter calls: before=%d after=%d", callsBeforeReplay, len(limiter.calls))
}
})
}

View file

@ -39,12 +39,16 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
if err != nil {
return nil, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
idempotencyFingerprint, err := rpcRequestFingerprint(req)
if err != nil {
return nil, internalErr()
}
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: bot.ID,
RandomID: req.RandomID,
SenderUserID: userID,
RecipientUserID: bot.ID,
RandomID: req.RandomID,
IdempotencyFingerprint: idempotencyFingerprint,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
@ -56,16 +60,20 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, internalErr()
return nil, messageSendErr(err)
}
users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
users = r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats = r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
}
return tgPrivateSendResultUpdates(res, req.RandomID, false, users, chats), nil
}
func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.MessagesSendBotRequestedPeerRequest) (tg.UpdatesClass, error) {
@ -122,7 +130,6 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
@ -139,7 +146,7 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
@ -147,9 +154,13 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
return nil, internalErr()
}
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
users = r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats = r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
}
return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil
}
func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {

View file

@ -67,15 +67,22 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
repeat, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
Bot: inputUser(f.bot),
RandomID: 1001,
ButtonText: "Open changed",
Data: `{"ok":false}`,
ButtonText: "Open",
Data: `{"ok":true}`,
})
if err != nil {
t.Fatalf("repeat send webview data: %v", err)
}
repeatMsg := repeat.(*tg.Updates).Updates[0].(*tg.UpdateNewMessage).Message.(*tg.MessageService)
if repeatMsg.ID != service.ID {
t.Fatalf("repeat message id = %d, want original %d", repeatMsg.ID, service.ID)
repeatUpdates := repeat.(*tg.Updates).Updates
if len(repeatUpdates) != 2 {
t.Fatalf("repeat updates len = %d, want immutable mapping + message confirmation", len(repeatUpdates))
}
mapping, ok := repeatUpdates[0].(*tg.UpdateMessageID)
if !ok || mapping.ID != service.ID || mapping.RandomID != 1001 {
t.Fatalf("repeat mapping = %+v (%T), want random_id 1001 -> original %d", repeatUpdates[0], repeatUpdates[0], service.ID)
}
if replayed, ok := repeatUpdates[1].(*tg.UpdateNewMessage); !ok || replayed.Pts <= 0 || replayed.PtsCount != 1 {
t.Fatalf("repeat confirmation = %T %+v, want UpdateNewMessage", repeatUpdates[1], repeatUpdates[1])
}
botHistory, err = f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{
HasPeer: true,
@ -85,6 +92,14 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
if err != nil || len(botHistory.Messages) != 1 {
t.Fatalf("bot history after repeat = %+v err=%v, want still one service message", botHistory, err)
}
if _, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
Bot: inputUser(f.bot),
RandomID: 1001,
ButtonText: "Open changed",
Data: `{"ok":false}`,
}); !tgerr.Is(err, "RANDOM_ID_DUPLICATE") {
t.Fatalf("conflicting webview random_id err = %v, want RANDOM_ID_DUPLICATE", err)
}
if _, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
Bot: inputUser(f.peer),

View file

@ -140,7 +140,7 @@ func (r *Router) onMessagesDeleteSavedHistory(ctx context.Context, req *tg.Messa
MinDate: minDate,
MaxDate: maxDate,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {

View file

@ -27,7 +27,7 @@ func (r *Router) onMessagesDeleteMessages(ctx context.Context, req *tg.MessagesD
IDs: req.ID,
Revoke: req.GetRevoke(),
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {
@ -102,7 +102,7 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
JustClear: req.GetJustClear(),
Revoke: req.GetRevoke(),
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {

View file

@ -68,7 +68,7 @@ func (r *Router) recordDraftMessageEvent(ctx context.Context, userID int64, peer
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordDraftMessage(ctx, authKeyID, userID, peer, topMsgID, sessionID)
event, state, err := r.deps.Updates.RecordDraftMessage(ctx, authKeyID, userID, peer, topMsgID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
r.log.Warn("record draft message event", zap.Int64("user_id", userID), zap.Error(err))
return domain.UpdateEvent{}
@ -433,7 +433,7 @@ func (r *Router) onMessagesUpdateDialogFilter(ctx context.Context, req *tg.Messa
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, req.ID, folder, sessionID)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, req.ID, folder, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -465,7 +465,7 @@ func (r *Router) onMessagesUpdateDialogFiltersOrder(ctx context.Context, order [
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFilterOrder(ctx, authKeyID, userID, clean, sessionID)
event, _, err = r.deps.Updates.RecordDialogFilterOrder(ctx, authKeyID, userID, clean, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -493,7 +493,7 @@ func (r *Router) onMessagesToggleDialogFilterTags(ctx context.Context, enabled b
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFiltersReload(ctx, authKeyID, userID, sessionID)
event, _, err = r.deps.Updates.RecordDialogFiltersReload(ctx, authKeyID, userID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -584,7 +584,7 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peers[0], pinned, folderID, sessionID)
event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peers[0], pinned, folderID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -631,7 +631,7 @@ func (r *Router) toggleArchiveFolderPin(ctx context.Context, userID int64, folde
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, folderPeer, pinned, 0, sessionID)
event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, folderPeer, pinned, 0, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -682,7 +682,7 @@ func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.Mes
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordPinnedDialogs(ctx, authKeyID, userID, req.FolderID, peers, sessionID)
event, state, err := r.deps.Updates.RecordPinnedDialogs(ctx, authKeyID, userID, req.FolderID, peers, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -741,7 +741,7 @@ func (r *Router) onMessagesMarkDialogUnread(ctx context.Context, req *tg.Message
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordDialogUnreadMark(ctx, authKeyID, userID, peers[0], unread, sessionID)
event, state, err := r.deps.Updates.RecordDialogUnreadMark(ctx, authKeyID, userID, peers[0], unread, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -819,7 +819,7 @@ func (r *Router) onMessagesHidePeerSettingsBar(ctx context.Context, input tg.Inp
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, domain.PeerSettings{HiddenPeerSettingsBar: true}, sessionID)
event, state, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, domain.PeerSettings{HiddenPeerSettingsBar: true}, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}

View file

@ -143,7 +143,6 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
return nil, messageEditForbiddenErr()
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{
OwnerUserID: userID,
Peer: peer,
@ -151,7 +150,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities),
EditDate: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,

View file

@ -94,7 +94,6 @@ func (r *Router) onEditMessageLiveLocation(ctx context.Context, req *tg.Messages
return nil, peerIDInvalidErr()
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{
OwnerUserID: userID,
Peer: peer,
@ -103,7 +102,7 @@ func (r *Router) onEditMessageLiveLocation(ctx context.Context, req *tg.Messages
Entities: current.entities,
Media: newMedia,
EditDate: now,
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {

View file

@ -61,7 +61,7 @@ func (r *Router) recordChannelDiscussionInbox(ctx context.Context, userID, chann
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
rec, _, err := r.deps.Updates.RecordChannelDiscussionInbox(ctx, authKeyID, userID, channelID, topicID, maxID, sessionID)
rec, _, err := r.deps.Updates.RecordChannelDiscussionInbox(ctx, authKeyID, userID, channelID, topicID, maxID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return internalErr()
}

View file

@ -43,11 +43,80 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if userID == 0 {
return nil, peerIDInvalidErr()
}
fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, req.ID, req.RandomID)
if !forwardMessageIDsValid(req.ID, req.RandomID) {
return nil, messageIDInvalidErr()
}
seenRandomIDs := make(map[int64]struct{}, len(req.RandomID))
for _, randomID := range req.RandomID {
if _, duplicate := seenRandomIDs[randomID]; duplicate {
return nil, randomIDDuplicateErr()
}
seenRandomIDs[randomID] = struct{}{}
}
toPeer, ok := r.domainPeerFromInputPeer(userID, req.ToPeer)
if !ok || toPeer.ID == 0 {
return nil, peerIDInvalidErr()
}
idempotencyFingerprints := make([][]byte, len(req.ID))
for i := range req.ID {
idempotencyFingerprints[i], err = forwardMessagesItemIdempotencyFingerprint(req, req.ID[i], req.RandomID[i])
if err != nil {
return nil, internalErr()
}
}
immediate := req.ScheduleDate == 0 || scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix()))
replays := make([]outgoingReplayLookup, len(req.ID))
absentIndexes := make([]int, 0, len(req.ID))
if immediate {
for i := range req.ID {
replay, err := r.lookupOutgoingReplay(ctx, userID, toPeer, req.RandomID[i], idempotencyFingerprints[i])
if err != nil {
return nil, err
}
replays[i] = replay
if !replay.found {
absentIndexes = append(absentIndexes, i)
}
}
} else {
for i := range req.ID {
absentIndexes = append(absentIndexes, i)
}
}
if len(absentIndexes) == 0 {
if toPeer.Type == domain.PeerTypeChannel {
results := make([]domain.SendChannelMessageResult, len(replays))
for i := range replays {
results[i] = replays[i].channel
}
return r.channelMessagesUpdatesWithPeerCache(ctx, userID, results, req.RandomID, true, nil, newViewerPeerCache(r)), nil
}
res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID}
for i := range replays {
sent := replays[i].private
res.SenderMessages = append(res.SenderMessages, sent.SenderMessage)
res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage)
res.SenderEvents = append(res.SenderEvents, sent.SenderEvent)
res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent)
res.Duplicates = append(res.Duplicates, true)
res.ReplayDeleteEvents = append(res.ReplayDeleteEvents, sent.ReplayDeleteEvent)
}
return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil
}
if err := r.checkSendRateLimit(ctx, userID, len(absentIndexes)); err != nil {
return nil, err
}
toPeer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer)
if err != nil {
return nil, err
}
toPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer)
absentIDs := make([]int, len(absentIndexes))
absentRandomIDs := make([]int64, len(absentIndexes))
for i, originalIndex := range absentIndexes {
absentIDs[i] = req.ID[originalIndex]
absentRandomIDs[i] = req.RandomID[originalIndex]
}
fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, absentIDs, absentRandomIDs)
if err != nil {
return nil, err
}
@ -75,72 +144,89 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
}
}
}
if !forwardMessageIDsValid(req.ID, req.RandomID) {
return nil, messageIDInvalidErr()
}
if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil {
return nil, err
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs, preloadedSources)
}
absentSources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, absentIDs, preloadedSources)
if err != nil {
return nil, messageForwardErr(err)
}
sources := make([]forwardSource, len(req.ID))
for i, originalIndex := range absentIndexes {
sources[originalIndex] = absentSources[i]
}
if toPeer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return nil, peerIDInvalidErr()
}
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
if err != nil {
return nil, messageForwardErr(err)
}
recipients := make([]int64, 0)
results := make([]domain.SendChannelMessageResult, 0, len(sources))
extraUserIDs := make([]int64, 0, len(sources))
fanoutResults := make([]domain.SendChannelMessageResult, 0, len(sources))
fanoutExtraUserIDs := make([]int64, 0, len(sources))
for i, source := range sources {
if replays[i].found {
results = append(results, replays[i].channel)
continue
}
forward := source.forward
if req.DropAuthor {
forward = nil
}
mentionUserIDs := r.mentionUserIDsFromDomain(ctx, userID, source.body, source.entities)
res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{
UserID: userID,
ChannelID: toPeer.ID,
RandomID: req.RandomID[i],
Message: source.body,
Entities: source.entities,
Media: source.media,
MentionUserIDs: mentionUserIDs,
Silent: req.Silent,
NoForwards: req.Noforwards,
ReplyTo: replyTo,
Forward: forward,
SendAs: sendAs,
Date: int(r.clock.Now().Unix()),
UserID: userID,
ChannelID: toPeer.ID,
RandomID: req.RandomID[i],
IdempotencyFingerprint: idempotencyFingerprints[i],
IdempotencyPreflighted: replays[i].checked,
Message: source.body,
Entities: source.entities,
Media: source.media,
MentionUserIDs: mentionUserIDs,
Silent: req.Silent,
NoForwards: req.Noforwards,
ReplyTo: replyTo,
Forward: forward,
SendAs: sendAs,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInvalidErr(err)
}
results = append(results, res)
sourceUserID := source.userID()
if sourceUserID != 0 {
extraUserIDs = append(extraUserIDs, sourceUserID)
}
// An exact random_id replay must still appear in the caller's echo, but it must not
// emit the old channel pts as a fresh realtime payload/Bot API update/discussion push.
// Mixed batches therefore fan out only newly committed results while preserving the
// complete result vector for TDesktop's random_id -> message-id reconciliation.
if res.Duplicate {
continue
}
fanoutResults = append(fanoutResults, res)
if sourceUserID != 0 {
fanoutExtraUserIDs = append(fanoutExtraUserIDs, sourceUserID)
}
// 收件人是该频道的活跃成员集,对本次转发的每条源都相同;只取一次,
// 避免一次转发 ≤100 条到 N 成员大群时把 recipients 累积成 ~100×N 条目
// 的巨大临时切片N=10^5 时约千万级)。
if len(recipients) == 0 {
recipients = res.Recipients
}
if sourceUserID := source.userID(); sourceUserID != 0 {
extraUserIDs = append(extraUserIDs, sourceUserID)
}
}
// echo 与 fan-out 用各自独立 cacheRPC vs worker goroutine 防竞态)。多条转发汇成
// 一个 fan-out jobchannelMessagesUpdatesWithPeerCache 内含多条 UpdateNewChannelMessage
// 由同 channel 分片 FIFO 原子投递。
echoCache := newViewerPeerCache(r)
updates := r.channelMessagesUpdatesWithPeerCache(ctx, userID, results, req.RandomID, true, extraUserIDs, echoCache)
fanoutPts := 0
if n := len(results); n > 0 {
fanoutPts = results[n-1].Event.Pts
if n := len(fanoutResults); n > 0 {
fanoutPts := fanoutResults[n-1].Event.Pts
r.enqueueChannelMessagesFanout(ctx, userID, toPeer.ID, fanoutPts, recipients, fanoutResults, fanoutExtraUserIDs)
}
r.enqueueChannelMessagesFanout(ctx, userID, toPeer.ID, fanoutPts, recipients, results, extraUserIDs)
for _, res := range results {
for _, res := range fanoutResults {
r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion)
}
return updates, nil
@ -153,17 +239,20 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if err != nil {
return nil, err
}
// 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在
// forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保
// 留 from_name避免私聊→私聊路径泄漏原作者可点击账号media 也随 source 透传。
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
if err != nil {
return nil, messageForwardErr(err)
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
authKeyID := rawAuthKeyIDForOrigin(ctx)
res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID}
for i, source := range sources {
if replays[i].found {
sent := replays[i].private
res.SenderMessages = append(res.SenderMessages, sent.SenderMessage)
res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage)
res.SenderEvents = append(res.SenderEvents, sent.SenderEvent)
res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent)
res.Duplicates = append(res.Duplicates, true)
res.ReplayDeleteEvents = append(res.ReplayDeleteEvents, sent.ReplayDeleteEvent)
continue
}
forward := source.forward
if req.DropAuthor {
forward = nil
@ -175,20 +264,22 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
forward = &saved
}
sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: toPeer.ID,
RandomID: req.RandomID[i],
Message: source.body,
Entities: source.entities,
Media: source.media,
Silent: req.Silent,
NoForwards: req.Noforwards,
ReplyTo: replyTo,
Forward: forward,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
SenderUserID: userID,
RecipientUserID: toPeer.ID,
RandomID: req.RandomID[i],
Message: source.body,
Entities: source.entities,
Media: source.media,
Silent: req.Silent,
NoForwards: req.Noforwards,
ReplyTo: replyTo,
Forward: forward,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
IdempotencyFingerprint: idempotencyFingerprints[i],
IdempotencyPreflighted: replays[i].checked,
})
if err != nil {
return nil, messageForwardErr(err)
@ -201,6 +292,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
res.SenderEvents = append(res.SenderEvents, sent.SenderEvent)
res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent)
res.Duplicates = append(res.Duplicates, sent.Duplicate)
res.ReplayDeleteEvents = append(res.ReplayDeleteEvents, sent.ReplayDeleteEvent)
}
return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil
}
@ -497,6 +589,8 @@ func messageForwardErr(err error) error {
return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
return randomIDDuplicateErr()
default:
return internalErr()
}
@ -532,6 +626,18 @@ func tgForwardMessagesUpdates(res domain.ForwardPrivateMessagesResult, randomIDs
Pts: pts,
PtsCount: ptsCount,
})
if i < len(res.ReplayDeleteEvents) {
if deleted := res.ReplayDeleteEvents[i]; deleted != nil && deleted.Pts > 0 && len(deleted.MessageIDs) > 0 {
updates = append(updates, &tg.UpdateDeleteMessages{
Messages: append([]int(nil), deleted.MessageIDs...),
Pts: deleted.Pts,
PtsCount: deleted.PtsCount,
})
if deleted.Date > date {
date = deleted.Date
}
}
}
if date == 0 {
date = event.Date
}

View file

@ -241,6 +241,88 @@ func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) {
}
}
func TestMessagesForwardMessagesChannelReplayDoesNotRepeatRealtimePayload(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "15550004011", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
source, err := users.Create(ctx, domain.User{AccessHash: 52, Phone: "15550004012", FirstName: "Source"})
if err != nil {
t.Fatalf("create source: %v", err)
}
channels := appchannels.NewService(memory.NewChannelStore())
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Forward Replay",
Megagroup: true,
Date: 1700001210,
})
if err != nil {
t.Fatalf("create target channel: %v", err)
}
messages := &captureMessages{
getMessagesListed: true,
list: domain.MessageList{Messages: []domain.Message{
{ID: 7, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: source.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: source.ID}, Date: 1700001207, Body: "seven"},
{ID: 5, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: source.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: source.ID}, Date: 1700001205, Body: "five"},
}},
}
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(users),
Messages: messages,
Channels: channels,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
reqCtx := WithSessionID(WithRawAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{1}), 71)
to := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
from := &tg.InputPeerUser{UserID: source.ID, AccessHash: source.AccessHash}
firstReq := &tg.MessagesForwardMessagesRequest{FromPeer: from, ToPeer: to, ID: []int{7}, RandomID: []int64{7001}}
if _, err := r.onMessagesForwardMessages(reqCtx, firstReq); err != nil {
t.Fatalf("first forward: %v", err)
}
firstPushes := len(sessions.pushedUserIDs())
if firstPushes == 0 {
t.Fatal("first forward produced no realtime payload")
}
if _, err := r.onMessagesForwardMessages(reqCtx, firstReq); err != nil {
t.Fatalf("full replay: %v", err)
}
if got := len(sessions.pushedUserIDs()); got != firstPushes {
t.Fatalf("full replay realtime pushes = %d, want unchanged %d", got, firstPushes)
}
mixedReq := &tg.MessagesForwardMessagesRequest{
FromPeer: from,
ToPeer: to,
ID: []int{7, 5},
RandomID: []int64{7001, 5001},
}
if _, err := r.onMessagesForwardMessages(reqCtx, mixedReq); err != nil {
t.Fatalf("mixed replay: %v", err)
}
if got := len(sessions.pushedUserIDs()); got != firstPushes+1 {
t.Fatalf("mixed replay realtime pushes = %d, want %d", got, firstPushes+1)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok {
t.Fatalf("mixed replay realtime payload = %T, want *tg.Updates", sessions.lastUserPush())
}
newMessages := 0
for _, update := range updates.Updates {
if _, ok := update.(*tg.UpdateNewChannelMessage); ok {
newMessages++
}
}
if newMessages != 1 {
t.Fatalf("mixed replay realtime new-message updates = %d, want only newly committed item", newMessages)
}
}
func TestMessagesForwardMessagesInfersPrivateSourceFromInputPeerEmpty(t *testing.T) {
const (
ownerID = int64(1780243210)

View file

@ -191,7 +191,7 @@ func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToCla
// sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。
// saved_peer 来自 reply_to 的 monoforum_peer_id;管理员可写任意订阅者子会话,普通订阅者只能写自己的。
func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) {
func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest, fingerprint []byte, preflighted bool) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
@ -215,13 +215,15 @@ func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer do
return nil, replyToMonoforumPeerInvalidErr()
}
res, err := r.deps.Channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID,
SenderUserID: userID,
SavedPeer: savedPeer,
RandomID: req.RandomID,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
Date: int(r.clock.Now().Unix()),
MonoforumID: mono.ID,
SenderUserID: userID,
SavedPeer: savedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
IdempotencyPreflighted: preflighted,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, messageSendErr(err)
@ -232,7 +234,7 @@ func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer do
// monoforumSendUpdates 给发送者构造回声 Updates:updateMessageID(关联 random_id)+ updateNewChannelMessage
// (monoforum 走 channel pts)。另一方经 monoforum 频道的 getChannelDifference 收取该 durable 事件。
func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) tg.UpdatesClass {
updates := make([]tg.UpdateClass, 0, 2)
updates := make([]tg.UpdateClass, 0, 3)
if res.Message.RandomID != 0 {
updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: res.Message.RandomID})
}
@ -243,10 +245,19 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
}
updates = append(updates, newMsg)
date := int(r.clock.Now().Unix())
if res.Duplicate && res.ReplayDeleteEvent != nil {
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil {
updates = append(updates, deleted)
}
if res.ReplayDeleteEvent.Date > date {
date = res.ReplayDeleteEvent.Date
}
}
return &tg.Updates{
Updates: updates,
Chats: r.monoforumChats(ctx, userID, mono),
Users: r.monoforumSubscriberUsers(ctx, userID, []domain.MonoforumDialog{{SavedPeer: savedPeer}}, []domain.ChannelMessage{res.Message}),
Date: int(r.clock.Now().Unix()),
Date: date,
}
}

View file

@ -18,7 +18,6 @@ func (r *Router) updatePrivatePinnedMessage(ctx context.Context, userID int64, p
if r.deps.Messages == nil {
return nil, notImplementedErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.PinPrivateMessage(ctx, userID, domain.PinPrivateMessageRequest{
OwnerUserID: userID,
@ -28,7 +27,7 @@ func (r *Router) updatePrivatePinnedMessage(ctx context.Context, userID int64, p
PmOneside: req.PmOneside,
Silent: req.Silent,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {
@ -81,7 +80,6 @@ func (r *Router) sendPinServiceMessage(ctx context.Context, userID, peerUserID i
if err != nil {
return domain.SendPrivateTextResult{}, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
@ -96,7 +94,7 @@ func (r *Router) sendPinServiceMessage(ctx context.Context, userID, peerUserID i
// 翻译为对端视角;客户端凭此渲染"X 置顶了「…」"预览。
ReplyTo: &domain.MessageReply{MessageID: pinnedBoxID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerUserID}},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
@ -121,7 +119,7 @@ func (r *Router) unpinAllPrivateMessages(ctx context.Context, userID int64, peer
OwnerUserID: userID,
Peer: peer,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {

View file

@ -198,7 +198,6 @@ func (r *Router) onMessagesSendQuickReplyMessages(ctx context.Context, req *tg.M
return nil, err
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID}
now := int(r.clock.Now().Unix())
for i, template := range list.Messages {
@ -209,7 +208,7 @@ func (r *Router) onMessagesSendQuickReplyMessages(ctx context.Context, req *tg.M
Message: template.Message,
Entities: append([]domain.MessageEntity(nil), template.Entities...),
Date: now,
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
@ -361,7 +360,7 @@ func (r *Router) recordQuickReplyMutation(ctx context.Context, userID int64, mut
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err := r.deps.Updates.RecordQuickReplyMutation(ctx, authKeyID, userID, mutation, sessionID)
event, _, err := r.deps.Updates.RecordQuickReplyMutation(ctx, authKeyID, userID, mutation, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return domain.UpdateEvent{}, internalErr()
}

View file

@ -28,7 +28,7 @@ func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (
OwnerUserID: userID,
IDs: ids,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: id,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {

View file

@ -330,7 +330,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
Peer: peer,
MaxID: req.MaxID,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: id,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
})
if err != nil {

View file

@ -177,7 +177,7 @@ func (r *Router) onMessagesToggleSavedDialogPin(ctx context.Context, req *tg.Mes
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordSavedDialogPinned(ctx, authKeyID, userID, peers[0], pinned, sessionID)
event, state, err := r.deps.Updates.RecordSavedDialogPinned(ctx, authKeyID, userID, peers[0], pinned, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
@ -223,7 +223,7 @@ func (r *Router) onMessagesReorderPinnedSavedDialogs(ctx context.Context, req *t
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordPinnedSavedDialogs(ctx, authKeyID, userID, peers, sessionID)
event, state, err := r.deps.Updates.RecordPinnedSavedDialogs(ctx, authKeyID, userID, peers, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}

View file

@ -44,11 +44,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = err
return nil, sendErr
}
// 消息特效:仅接受 catalog 内的合法 effect id非法 → EFFECT_ID_INVALID官方行为
if r.messageEffectInvalid(ctx, req.Effect) {
sendErr = effectIDInvalidErr()
return nil, sendErr
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
sendErr = internalErr()
@ -58,25 +53,73 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = peerIDInvalidErr()
return nil, sendErr
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
sendErr = err
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
sendErr = peerIDInvalidErr()
return nil, sendErr
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
idempotencyFingerprint, err := sendMessageIdempotencyFingerprint(req)
if err != nil {
sendErr = err
sendErr = internalErr()
return nil, sendErr
}
// 频道私信(monoforum):仅当 reply_to 带 monoforum_peer_id 时走专用发送路径(普通发送恒不带,
// 故此 gate 对普通发送零额外成本)。peer 解析为 monoforum 频道时按订阅者子会话发送。
if monoforumReplyPresent(req.ReplyTo) {
updates, err := r.sendMonoforumMessage(ctx, userID, peer, req)
savedPeer, valid := r.monoforumReplyTargetPeer(userID, req.ReplyTo)
if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 {
sendErr = replyToMonoforumPeerInvalidErr()
return nil, sendErr
}
replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
return nil, err
}
if replay.found {
duplicate = true
return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
sendErr = err
return nil, sendErr
}
checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
sendErr = err
return nil, sendErr
}
updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, req, idempotencyFingerprint, replay.checked)
if err != nil {
sendErr = err
return nil, sendErr
}
return updates, nil
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
return nil, err
}
if replay.found {
duplicate = true
return r.outgoingReplayUpdates(ctx, userID, peer, req.RandomID, replay), nil
}
// Mutable catalog state, rate accounting and access checks are intentionally after exact
// replay lookup: a committed send remains acknowledgeable after those states change.
if r.messageEffectInvalid(ctx, req.Effect) {
sendErr = effectIDInvalidErr()
return nil, sendErr
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
sendErr = err
return nil, sendErr
}
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
sendErr = err
return nil, sendErr
}
// reply_markupbot inline keyboard仅 bot 账号发送被接受+校验;非 bot 静默丢弃。
// 仅在请求携带 markup 时才查 is_bot避免普通发送多打一次查询。
var replyMarkup *domain.MessageReplyMarkup
@ -114,16 +157,18 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
updates, err := r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: req.Message,
entities: req.Entities,
media: previewMedia,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
richMessage: richMessage,
randomID: req.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked,
message: req.Message,
entities: req.Entities,
media: previewMedia,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
richMessage: richMessage,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
if err != nil {
sendErr = err
@ -132,18 +177,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return updates, nil
}
updates, dup, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: req.Message,
entities: req.Entities,
media: previewMedia,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
richMessage: richMessage,
effect: req.Effect,
randomID: req.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked,
message: req.Message,
entities: req.Entities,
media: previewMedia,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
richMessage: richMessage,
effect: req.Effect,
})
duplicate = dup
if err != nil {
@ -159,6 +206,8 @@ func messageSendErr(err error) error {
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
return randomIDDuplicateErr()
default:
return internalErr()
}
@ -409,6 +458,33 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
}
}
// tgPrivateSendResultUpdates returns a complete send acknowledgement for exact
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
// transition its local pending message to SENT. Visible edited messages use the
// current snapshot; deleted messages use the immutable first snapshot followed
// by the already-durable delete event, so the acknowledgement cannot become a
// permanent resurrection. No replay allocates pts or emits fan-out.
func tgPrivateSendResultUpdates(res domain.SendPrivateTextResult, randomID int64, includeMessageIDForNew bool, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates {
if !res.Duplicate {
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, randomID, includeMessageIDForNew, users, chats)
}
if randomID == 0 {
randomID = res.SenderMessage.RandomID
}
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, randomID, randomID != 0, users, chats)
if event := res.ReplayDeleteEvent; event != nil && event.Pts > 0 && len(event.MessageIDs) > 0 {
out.Updates = append(out.Updates, &tg.UpdateDeleteMessages{
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
})
if event.Date > out.Date {
out.Date = event.Date
}
}
return out
}
func (r *Router) usersForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.UserClass {
seen := make(map[int64]struct{}, 2)
users := make([]tg.UserClass, 0, 2)

View file

@ -52,6 +52,9 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
if messages.sendUserID != sender.ID || messages.sendReq.SenderUserID != sender.ID || messages.sendReq.RecipientUserID != recipient.ID || messages.sendReq.OriginSessionID != 77 {
t.Fatalf("send context = user %d req %+v, want sender/recipient/session", messages.sendUserID, messages.sendReq)
}
if len(messages.sendReq.IdempotencyFingerprint) != 32 {
t.Fatalf("idempotency fingerprint length = %d, want SHA-256", len(messages.sendReq.IdempotencyFingerprint))
}
if len(messages.sendReq.Entities) != 2 || messages.sendReq.Entities[0].Type != domain.MessageEntityBold {
t.Fatalf("entities = %+v, want bold and formatted date converted to domain", messages.sendReq.Entities)
}

View file

@ -243,7 +243,6 @@ func (r *Router) mutateTodoMedia(ctx context.Context, inputPeer tg.InputPeerClas
return nil, peerIDInvalidErr()
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{
OwnerUserID: userID,
Peer: peer,
@ -252,7 +251,7 @@ func (r *Router) mutateTodoMedia(ctx context.Context, inputPeer tg.InputPeerClas
Entities: current.entities,
Media: newMedia,
EditDate: now,
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
AllowTodoParticipantMutation: participantEdit,
})

View file

@ -18,7 +18,10 @@ import (
const (
defaultOutboxBatch = 100
defaultOutboxInterval = 200 * time.Millisecond
defaultOutboxWorkers = 1
defaultOutboxWorkers = 4
// outboxLogicalShards 是稳定 user→lane 哈希空间。它不随运行时 worker 数变化,
// worker 只独占其中一组 shard保证同一用户始终单 lane 串行、不同用户可并行。
outboxLogicalShards = store.DispatchOutboxLogicalShards
// defaultOutboxMaxIdleInterval 是空闲退避上界:连续空 claim 时把轮询间隔从 interval
// 指数退避到此上界,削减「无消息时也每 200ms 查一次 DB」的空转一旦有活就立刻复位到
// interval。代价是「长时间静默后的第一条消息」最多多等这个上界——退避只在持续空闲时触及
@ -138,25 +141,61 @@ func (d *OutboxDispatcher) Run(ctx context.Context) {
if d == nil || d.events == nil || d.outbox == nil || d.sessions == nil {
return
}
workers := d.workers
if workers < 1 {
workers = 1
}
claimer, sharded := d.outbox.(shardedOutboxClaimer)
workers := normalizedOutboxWorkers(d.workers, sharded)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
var shardIDs []int
if sharded {
shardIDs = logicalShardsForWorker(i, workers)
}
go func() {
defer wg.Done()
d.runWorker(ctx)
d.runWorker(ctx, claimer, shardIDs)
}()
}
wg.Wait()
}
func normalizedOutboxWorkers(workers int, sharded bool) int {
if workers < 1 {
workers = 1
}
if !sharded {
// 测试替身或旧 store 没有 user shard claim 时,强制单 worker避免同一用户
// 的不同 pts 被并行领取。
return 1
}
// 多于 logical shard 的 worker 没有独占 lane。若让空 shard worker 回退全局
// claim会与全部分片 worker 重叠,因此必须在启动前钳制。
if workers > outboxLogicalShards {
return outboxLogicalShards
}
return workers
}
func logicalShardsForWorker(worker, workers int) []int {
if workers <= 0 || worker < 0 || worker >= workers {
return nil
}
out := make([]int, 0, (outboxLogicalShards+workers-1)/workers)
for shard := worker; shard < outboxLogicalShards; shard += workers {
out = append(out, shard)
}
return out
}
// runWorker 是单个 claim 循环;多 worker 靠 ClaimPending 的 SKIP LOCKED 互不重叠。
// 空闲指数退避interval→maxIdleIntervalclaim 到事件即复位到 interval有活快、无活省。
func (d *OutboxDispatcher) runWorker(ctx context.Context) {
runIdleBackoffLoop(ctx, d.interval, d.maxIdleInterval, d.DispatchOnce)
func (d *OutboxDispatcher) runWorker(ctx context.Context, claimer shardedOutboxClaimer, shardIDs []int) {
dispatch := d.DispatchOnce
if claimer != nil && len(shardIDs) > 0 {
dispatch = func(ctx context.Context) bool {
return d.dispatchOnceShards(ctx, claimer, shardIDs)
}
}
runIdleBackoffLoop(ctx, d.interval, d.maxIdleInterval, dispatch)
}
// batchEventLoader 是 UpdateEventStore 的可选批量能力:一次取多条 (user,pts) 事件。
@ -169,11 +208,24 @@ type batchOutboxMarker interface {
MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error
}
type shardedOutboxClaimer interface {
ClaimPendingShards(ctx context.Context, shardCount int, shardIDs []int, limit int) ([]store.DispatchOutboxItem, error)
}
// DispatchOnce claim 一批 outbox 并投递,测试可直接调用。返回本次是否 claim 到事件,
// 供 runWorker 决定快轮询还是空闲退避。
// store 同时具备批量取事件 + 批量标记能力时走批量路径(每批 ~3 次 PG 往返),否则逐条回退。
func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) bool {
items, err := d.outbox.ClaimPending(ctx, d.batch)
return d.dispatchClaimed(ctx, items, err)
}
func (d *OutboxDispatcher) dispatchOnceShards(ctx context.Context, claimer shardedOutboxClaimer, shardIDs []int) bool {
items, err := claimer.ClaimPendingShards(ctx, outboxLogicalShards, shardIDs, d.batch)
return d.dispatchClaimed(ctx, items, err)
}
func (d *OutboxDispatcher) dispatchClaimed(ctx context.Context, items []store.DispatchOutboxItem, err error) bool {
if err != nil {
d.log.Warn("claim dispatch outbox", zap.Error(err))
return false
@ -189,8 +241,14 @@ func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) bool {
return true
}
}
blockedUsers := make(map[int64]struct{})
for _, item := range items {
d.dispatchItem(ctx, item)
if _, blocked := blockedUsers[item.TargetUserID]; blocked {
continue
}
if !d.dispatchItem(ctx, item) {
blockedUsers[item.TargetUserID] = struct{}{}
}
}
return true
}
@ -223,8 +281,14 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp
if err != nil {
// 批量取失败则整批回退逐条路径,让每条各自重试/标失败,不丢进度。
d.log.Warn("batch load dispatch events", zap.Error(err))
blockedUsers := make(map[int64]struct{})
for _, item := range items {
d.dispatchItem(ctx, item)
if _, blocked := blockedUsers[item.TargetUserID]; blocked {
continue
}
if !d.dispatchItem(ctx, item) {
blockedUsers[item.TargetUserID] = struct{}{}
}
}
return
}
@ -235,10 +299,15 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp
start := time.Now()
ready := make([]outboxDispatchReady, 0, len(items))
requests := make([]OutboxUpdateRequest, 0, len(items))
blockedUsers := make(map[int64]struct{})
for _, item := range items {
if _, blocked := blockedUsers[item.TargetUserID]; blocked {
continue
}
event, ok := byKey[outboxEventKey{item.TargetUserID, item.Pts}]
if !ok {
d.markDispatchFailed(ctx, item, errMissingOutboxEvent)
blockedUsers[item.TargetUserID] = struct{}{}
continue
}
ready = append(ready, outboxDispatchReady{item: item})
@ -246,14 +315,19 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp
}
builtUpdates := d.buildOutboxUpdates(ctx, requests)
delivered := make([]store.DispatchOutboxItem, 0, len(items))
clear(blockedUsers)
for i, entry := range ready {
item := entry.item
if _, blocked := blockedUsers[item.TargetUserID]; blocked {
continue
}
update := builtUpdates[i]
if update == nil {
delivered = append(delivered, item)
continue
}
if _, retriable, err := d.pushOutboxUpdate(ctx, item, update); err != nil {
blockedUsers[item.TargetUserID] = struct{}{}
if retriable {
// 出站队列拥塞:留 dispatching 行靠租约过期重投,不计入 attempts 升级。
// 不加入 delivered故不会被 MarkDeliveredBatch 删除。
@ -271,7 +345,7 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp
// 批量标记失败则逐条标记,避免整批已投递却卡在 dispatching 等租约过期重投。
d.log.Warn("mark dispatch delivered batch", zap.Error(err))
for _, item := range delivered {
if markErr := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); markErr != nil {
if markErr := d.outbox.MarkDelivered(ctx, item); markErr != nil {
d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(markErr))
}
}
@ -286,25 +360,25 @@ type outboxDispatchReady struct {
item store.DispatchOutboxItem
}
func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.DispatchOutboxItem) {
func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.DispatchOutboxItem) bool {
start := time.Now()
events, err := d.events.ListAfter(ctx, item.TargetUserID, item.Pts-1, 1)
if err != nil {
d.markDispatchFailed(ctx, item, err)
return
return false
}
if len(events) == 0 || events[0].Pts != item.Pts {
d.markDispatchFailed(ctx, item, errMissingOutboxEvent)
return
return false
}
update := d.buildOutboxUpdate(ctx, item, events[0])
if update == nil {
if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil {
if err := d.outbox.MarkDelivered(ctx, item); err != nil {
d.log.Warn("mark noop dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err))
return
return false
}
d.metrics.OutboxDelivered(time.Since(start))
return
return true
}
sent, retriable, err := d.pushOutboxUpdate(ctx, item, update)
if err != nil {
@ -316,14 +390,14 @@ func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.Dispatch
zap.Int64("outbox_id", item.ID),
zap.Int("pts", item.Pts),
)
return
return false
}
d.markDispatchFailed(ctx, item, err)
return
return false
}
if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil {
if err := d.outbox.MarkDelivered(ctx, item); err != nil {
d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err))
return
return false
}
d.metrics.OutboxDelivered(time.Since(start))
d.log.Debug("dispatch outbox delivered",
@ -332,6 +406,7 @@ func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.Dispatch
zap.Int("pts", item.Pts),
zap.Int("sessions", sent),
)
return true
}
func (d *OutboxDispatcher) buildOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, event domain.UpdateEvent) *tg.Updates {
@ -364,19 +439,19 @@ func (d *OutboxDispatcher) buildOutboxUpdates(ctx context.Context, requests []Ou
}
// pushOutboxUpdate 投递一条 outbox update返回 (送达的在线 session 数, 是否可重试, err)。
// best-effort 路径pushTimeout>0的失败只可能是出站队列拥塞慢消费者入队超时属暂时性、
// 可重试:调用方应保留 dispatching 行靠租约过期重投,而非计入 attempts 升级为 failed。
// 可靠路径的失败是真实投递错误retriable=false按原逻辑退避升级
// 生产 SessionManager 会把 queue-full/closed 慢连接摘除并按离线处理,因此 best-effort
// 接口剩余的非 context 错误通常是确定性的编码/构造错误,必须进入 failed不能永久占着
// dispatching head 靠租约空转。只有 dispatcher shutdown/deadline 属于可重试中断
func (d *OutboxDispatcher) pushOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, update *tg.Updates) (sent int, retriable bool, err error) {
var zeroAuthKeyID [8]byte
if d.pushTimeout > 0 {
if scoped, ok := d.sessions.(ScopedBestEffortSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID {
sent, err = scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout)
return sent, err != nil, err
return sent, outboxPushInterrupted(err), err
}
if bestEffort, ok := d.sessions.(BestEffortSessionBinder); ok {
sent, err = bestEffort.PushToUserExceptSessionBestEffort(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout)
return sent, err != nil, err
return sent, outboxPushInterrupted(err), err
}
}
if scoped, ok := d.sessions.(ScopedSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID {
@ -387,11 +462,15 @@ func (d *OutboxDispatcher) pushOutboxUpdate(ctx context.Context, item store.Disp
return sent, false, err
}
func outboxPushInterrupted(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
func (d *OutboxDispatcher) markDispatchFailed(ctx context.Context, item store.DispatchOutboxItem, err error) {
if err == nil {
err = errMissingOutboxEvent
}
if markErr := d.outbox.MarkFailed(ctx, item.TargetUserID, item.ID, err.Error()); markErr != nil {
if markErr := d.outbox.MarkFailed(ctx, item, err.Error()); markErr != nil {
d.log.Warn("mark dispatch failed",
zap.Int64("target_user_id", item.TargetUserID),
zap.Int64("outbox_id", item.ID),

View file

@ -460,6 +460,101 @@ func TestOutboxDispatcherOrdersClaimedItemsByUserPts(t *testing.T) {
}
}
func TestOutboxLogicalShardsAreDisjointAndStable(t *testing.T) {
for _, workers := range []int{1, 2, 4, 7, 64, outboxLogicalShards} {
seen := make([]int, outboxLogicalShards)
for worker := 0; worker < workers; worker++ {
for _, shard := range logicalShardsForWorker(worker, workers) {
if shard < 0 || shard >= outboxLogicalShards {
t.Fatalf("workers=%d worker=%d returned invalid shard %d", workers, worker, shard)
}
seen[shard]++
}
}
for shard, owners := range seen {
if owners != 1 {
t.Fatalf("workers=%d shard=%d owners=%d, want exactly one", workers, shard, owners)
}
}
}
if got := normalizedOutboxWorkers(8, false); got != 1 {
t.Fatalf("non-sharded workers = %d, want 1", got)
}
if got := normalizedOutboxWorkers(outboxLogicalShards+100, true); got != outboxLogicalShards {
t.Fatalf("overprovisioned workers = %d, want clamp %d", got, outboxLogicalShards)
}
}
func TestOutboxDispatcherBatchFailureBlocksHigherUserPts(t *testing.T) {
const (
blockedUser = int64(1000000002)
otherUser = int64(1000000003)
)
items := []store.DispatchOutboxItem{
{ID: 12, TargetUserID: blockedUser, Pts: 12, EventType: domain.UpdateEventReadHistoryInbox},
{ID: 5, TargetUserID: otherUser, Pts: 5, EventType: domain.UpdateEventReadHistoryInbox},
{ID: 11, TargetUserID: blockedUser, Pts: 11, EventType: domain.UpdateEventReadHistoryInbox},
}
events := make([]domain.UpdateEvent, 0, len(items))
for _, item := range items {
events = append(events, outboxReadEvent(item.TargetUserID, item.Pts))
}
eventStore := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: events}}
outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: items}}
sessions := &selectiveFailOutboxSessions{failUserID: blockedUser, failPts: 11}
dispatcher := NewOutboxDispatcher(eventStore, outbox, sessions, zaptest.NewLogger(t))
dispatcher.DispatchOnce(context.Background())
wantAttempts := []outboxPushAttempt{{userID: blockedUser, pts: 11}, {userID: otherUser, pts: 5}}
if got := sessions.pushAttempts(); !reflect.DeepEqual(got, wantAttempts) {
t.Fatalf("push attempts = %+v, want %+v (blocked user's pts=12 must not overtake failed pts=11)", got, wantAttempts)
}
if !outbox.failed || len(outbox.deliveredBatch) != 1 || outbox.deliveredBatch[0].TargetUserID != otherUser {
t.Fatalf("outbox failed=%v delivered=%+v, want failed head and only other user delivered", outbox.failed, outbox.deliveredBatch)
}
}
func TestOutboxDispatcherBatchLoadFallbackStillBlocksHigherUserPts(t *testing.T) {
const (
blockedUser = int64(1000000004)
otherUser = int64(1000000005)
)
items := []store.DispatchOutboxItem{
{ID: 22, TargetUserID: blockedUser, Pts: 22, EventType: domain.UpdateEventReadHistoryInbox},
{ID: 21, TargetUserID: blockedUser, Pts: 21, EventType: domain.UpdateEventReadHistoryInbox},
{ID: 6, TargetUserID: otherUser, Pts: 6, EventType: domain.UpdateEventReadHistoryInbox},
}
events := make([]domain.UpdateEvent, 0, len(items))
for _, item := range items {
events = append(events, outboxReadEvent(item.TargetUserID, item.Pts))
}
eventStore := &failingBatchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: events}}
outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: items}}
sessions := &selectiveFailOutboxSessions{failUserID: blockedUser, failPts: 21}
dispatcher := NewOutboxDispatcher(eventStore, outbox, sessions, zaptest.NewLogger(t))
dispatcher.DispatchOnce(context.Background())
wantAttempts := []outboxPushAttempt{{userID: blockedUser, pts: 21}, {userID: otherUser, pts: 6}}
if got := sessions.pushAttempts(); !reflect.DeepEqual(got, wantAttempts) {
t.Fatalf("fallback push attempts = %+v, want %+v", got, wantAttempts)
}
}
func outboxReadEvent(userID int64, pts int) domain.UpdateEvent {
return domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Pts: pts,
PtsCount: 1,
Date: 1700000000 + pts,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 999},
MaxID: pts,
StillUnreadCount: 0,
}
}
type outboxUsersCall struct {
viewerUserID int64
ids []int64
@ -602,6 +697,34 @@ type orderedOutboxCaptureSessions struct {
pushed []int
}
type outboxPushAttempt struct {
userID int64
pts int
}
type selectiveFailOutboxSessions struct {
captureSessions
failUserID int64
failPts int
attempts []outboxPushAttempt
}
func (s *selectiveFailOutboxSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
pts := 0
if updates, ok := msg.(*tg.Updates); ok {
pts = firstOutboxUpdatePts(updates)
}
s.attempts = append(s.attempts, outboxPushAttempt{userID: userID, pts: pts})
if userID == s.failUserID && pts == s.failPts {
return 0, errors.New("injected outbox push failure")
}
return s.captureSessions.PushToUserExceptSession(context.Background(), userID, excludeSessionID, t, msg)
}
func (s *selectiveFailOutboxSessions) pushAttempts() []outboxPushAttempt {
return append([]outboxPushAttempt(nil), s.attempts...)
}
func (s *orderedOutboxCaptureSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
if updates, ok := msg.(*tg.Updates); ok {
s.pushed = append(s.pushed, firstOutboxUpdatePts(updates))
@ -635,6 +758,33 @@ type batchEventStore struct {
batchCursors []store.EventCursor
}
type failingBatchEventStore struct {
*captureUpdateEventStore
}
func (s *failingBatchEventStore) BatchByCursor(context.Context, []store.EventCursor) ([]domain.UpdateEvent, error) {
return nil, errors.New("injected batch event load failure")
}
func (s *failingBatchEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) {
if limit <= 0 {
return nil, nil
}
var next domain.UpdateEvent
for _, event := range s.events {
if event.UserID != userID || event.Pts <= pts {
continue
}
if next.Pts == 0 || event.Pts < next.Pts {
next = event
}
}
if next.Pts == 0 {
return nil, nil
}
return []domain.UpdateEvent{next}, nil
}
func (s *batchEventStore) BatchByCursor(_ context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) {
s.batchCursors = cursors
out := make([]domain.UpdateEvent, 0, len(cursors))
@ -737,6 +887,8 @@ type captureScopedSessions struct {
scopedMu sync.Mutex
scopedAuthKeyID [8]byte
immediatePush bool
immediateType proto.MessageType
immediateMsg bin.Encoder
}
func (s *captureScopedSessions) setScopedAuthKeyID(rawAuthKeyID [8]byte) {
@ -757,6 +909,12 @@ func (s *captureScopedSessions) immediatePushSeen() bool {
return s.immediatePush
}
func (s *captureScopedSessions) immediatePushSnapshot() (proto.MessageType, bin.Encoder) {
s.scopedMu.Lock()
defer s.scopedMu.Unlock()
return s.immediateType, s.immediateMsg
}
func (s *captureScopedSessions) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) {
s.BindAuthKey(sessionID, authKeyID)
s.setScopedAuthKeyID(rawAuthKeyID)
@ -790,6 +948,8 @@ func (s *captureScopedSessions) PushToSessionForAuthKeyImmediate(_ context.Conte
s.scopedMu.Lock()
s.immediatePush = true
s.scopedAuthKeyID = rawAuthKeyID
s.immediateType = t
s.immediateMsg = msg
s.scopedMu.Unlock()
return s.PushToSession(context.Background(), sessionID, t, msg)
}
@ -805,14 +965,14 @@ func (s *captureDispatchOutbox) ClaimPending(context.Context, int) ([]store.Disp
return items, nil
}
func (s *captureDispatchOutbox) MarkDelivered(_ context.Context, targetUserID, id int64) error {
func (s *captureDispatchOutbox) MarkDelivered(_ context.Context, item store.DispatchOutboxItem) error {
s.delivered = true
s.deliveredUserID = targetUserID
s.deliveredID = id
s.deliveredUserID = item.TargetUserID
s.deliveredID = item.ID
return nil
}
func (s *captureDispatchOutbox) MarkFailed(_ context.Context, _ int64, _ int64, lastError string) error {
func (s *captureDispatchOutbox) MarkFailed(_ context.Context, _ store.DispatchOutboxItem, lastError string) error {
s.failed = true
s.failedError = lastError
return nil
@ -869,21 +1029,19 @@ func (m *captureOutboxMetrics) OutboxFailed(error) {
m.failed++
}
// queueFullBestEffortSessions 模拟出站队列拥塞best-effort 推送总是失败(入队超时 / 队列满)
type queueFullBestEffortSessions struct {
// interruptedBestEffortSessions 模拟 dispatcher context 到期:该中断可安全靠 lease 重试
type interruptedBestEffortSessions struct {
*captureSessions
attempts int
}
func (s *queueFullBestEffortSessions) PushToUserExceptSessionBestEffort(_ context.Context, _ int64, _ int64, _ proto.MessageType, _ bin.Encoder, _ time.Duration) (int, error) {
func (s *interruptedBestEffortSessions) PushToUserExceptSessionBestEffort(_ context.Context, _ int64, _ int64, _ proto.MessageType, _ bin.Encoder, _ time.Duration) (int, error) {
s.attempts++
return 0, errors.New("mtproto outbound queue full")
return 0, context.DeadlineExceeded
}
// TestOutboxDispatcherDefersOnPushQueueFull 验证 best-effort 推送因出站队列拥塞失败时dispatcher
// 既不标记 delivered任务保留靠 dispatching 租约过期重投,满足至少一次投递语义),也不标记
// failed拥塞不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed
func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) {
// TestOutboxDispatcherDefersOnPushInterruption 验证 shutdown/deadline 不把 lane head 误打 failed。
func TestOutboxDispatcherDefersOnPushInterruption(t *testing.T) {
msg := domain.Message{
ID: 10,
OwnerUserID: 1000000002,
@ -909,7 +1067,7 @@ func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) {
EventType: domain.UpdateEventNewMessage,
ExcludeSessionID: 99,
}}}
sessions := &queueFullBestEffortSessions{captureSessions: &captureSessions{}}
sessions := &interruptedBestEffortSessions{captureSessions: &captureSessions{}}
metrics := &captureOutboxMetrics{}
dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond), WithOutboxMetrics(metrics))
dispatcher.DispatchOnce(context.Background())
@ -918,12 +1076,21 @@ func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) {
t.Fatalf("best-effort push attempts = %d, want 1应走 best-effort 推送路径)", sessions.attempts)
}
if outbox.delivered {
t.Fatalf("outbox delivered=true, want 未投递(拥塞应保留 dispatching 行靠租约重投)")
t.Fatalf("outbox delivered=true, want 未投递(中断应保留 dispatching 行靠租约重投)")
}
if outbox.failed {
t.Fatalf("outbox failed=true, want 未失败(拥塞不计入 attempts 升级)")
t.Fatalf("outbox failed=true, want 未失败(context 中断不计入 attempts 升级)")
}
if metrics.failed != 0 {
t.Fatalf("metrics.failed=%d, want 0拥塞不算投递失败", metrics.failed)
t.Fatalf("metrics.failed=%d, want 0context 中断不算投递失败)", metrics.failed)
}
}
func TestOutboxPushInterruptedRejectsDeterministicErrors(t *testing.T) {
if !outboxPushInterrupted(context.Canceled) || !outboxPushInterrupted(context.DeadlineExceeded) {
t.Fatal("context shutdown/deadline must remain retriable")
}
if outboxPushInterrupted(errors.New("encode update: invalid constructor")) {
t.Fatal("deterministic encoding error must fail the lane head instead of lease-retrying forever")
}
}

View file

@ -340,7 +340,6 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
if err != nil {
return domain.SendPrivateTextResult{}, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
sticker := gift.Sticker
media := &domain.MessageMedia{
@ -368,7 +367,7 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
Media: media,
Silent: false,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})

View file

@ -146,10 +146,11 @@ func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req
return nil, err
}
now := int(r.clock.Now().Unix())
randomID := conferenceInviteRandomID(scope.call.ID, target.ID, now)
res, err := r.deps.Messages.SendPrivateText(ctx, scope.userID, domain.SendPrivateTextRequest{
SenderUserID: scope.userID,
RecipientUserID: target.ID,
RandomID: conferenceInviteRandomID(scope.call.ID, target.ID, now),
RandomID: randomID,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
@ -164,13 +165,16 @@ func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req
},
},
Date: now,
OriginAuthKeyID: authKeyIDFromCtx(ctx),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDFromCtx(ctx),
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, messageSendErr(err)
}
// Message and invite index currently live in separate stores/transactions. Always
// retry the idempotent invite write, including an exact message replay, so a crash
// after SendPrivateText commit cannot leave an unrecoverable message-without-index.
invite, err := r.deps.GroupCalls.CreateConferenceInvite(ctx, domain.GroupCallInvite{
CallID: scope.call.ID,
InviterUserID: scope.userID,
@ -184,6 +188,15 @@ func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req
return nil, groupCallErr(err)
}
_ = invite
if res.Duplicate {
// The first transaction already created both private boxes and their durable
// update events. Replaying UpdateNewMessage here would reconstruct the service
// message from the intentionally minimal immutable receipt and push it to the
// invitee a second time. Only reconcile the caller's random_id and original pts;
// The invite write above is an idempotent saga repair only; no message/update
// side effect is repeated.
return tgPrivateSendResultUpdates(res, randomID, true, nil, nil), nil
}
users := r.tgUsersForIDs(ctx, scope.userID, []int64{scope.userID, target.ID})
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, nil)
recipientUsers := r.tgUsersForIDs(ctx, target.ID, []int64{scope.userID, target.ID})

View file

@ -799,6 +799,82 @@ func TestConferenceInviteMessageResolvesInputGroupCallInviteMessage(t *testing.T
}
}
func TestConferenceInviteExactReplayConfirmsImmutableMessageWithoutFanout(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 89})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
request := &tg.PhoneInviteConferenceCallParticipantRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
}
first, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, request)
if err != nil {
t.Fatalf("first conference invite: %v", err)
}
firstUpdate := findUpdate[*tg.UpdateNewMessage](t, first)
firstMessage, ok := firstUpdate.Message.(*tg.MessageService)
if !ok {
t.Fatalf("first conference message = %T, want MessageService", firstUpdate.Message)
}
bobHistory, err := f.messages.GetHistory(f.ctx, f.bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.alice.ID},
Limit: 10,
})
if err != nil || len(bobHistory.Messages) != 1 {
t.Fatalf("bob history after first invite len=%d err=%v, want one", len(bobHistory.Messages), err)
}
bobMessageID := bobHistory.Messages[0].ID
_, firstInvite, found, err := f.group.GetByInviteMessage(f.ctx, f.bob.ID, bobMessageID)
if err != nil || !found {
t.Fatalf("first durable invite = %+v found=%v err=%v", firstInvite, found, err)
}
f.sessions.reset()
replay, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, request)
if err != nil {
t.Fatalf("replay conference invite: %v", err)
}
replayed := findUpdate[*tg.UpdateNewMessage](t, replay)
replayedMessage, ok := replayed.Message.(*tg.MessageService)
if !ok || replayedMessage.ID != firstMessage.ID || replayed.Pts != firstUpdate.Pts || replayed.PtsCount != firstUpdate.PtsCount {
t.Fatalf("replay message = %+v (%T), want original confirmation %d pts %d/%d", replayed.Message, replayed.Message, firstMessage.ID, firstUpdate.Pts, firstUpdate.PtsCount)
}
mapping := findUpdate[*tg.UpdateMessageID](t, replay)
wantRandomID := conferenceInviteRandomID(call.ID, f.bob.ID, int(f.clock.Now().Unix()))
if mapping.ID != firstMessage.ID || mapping.RandomID != wantRandomID {
t.Fatalf("replay mapping = %+v, want id/random_id %d/%d", mapping, firstMessage.ID, wantRandomID)
}
if records := f.sessions.records(); len(records) != 0 {
t.Fatalf("replay must not fan out another invite, got %+v", records)
}
bobHistory, err = f.messages.GetHistory(f.ctx, f.bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.alice.ID},
Limit: 10,
})
if err != nil || len(bobHistory.Messages) != 1 {
t.Fatalf("bob history after replay len=%d err=%v, want original one", len(bobHistory.Messages), err)
}
_, replayInvite, found, err := f.group.GetByInviteMessage(f.ctx, f.bob.ID, bobMessageID)
if err != nil || !found || replayInvite != firstInvite {
t.Fatalf("durable invite after replay = %+v found=%v err=%v, want unchanged %+v", replayInvite, found, err, firstInvite)
}
conflict := *request
conflict.Video = true
if result, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, &conflict); result != nil || !tgerr.Is(err, "RANDOM_ID_DUPLICATE") {
t.Fatalf("conflicting replay = %+v err=%v, want RANDOM_ID_DUPLICATE", result, err)
}
if records := f.sessions.records(); len(records) != 0 {
t.Fatalf("conflicting replay must not fan out, got %+v", records)
}
}
func TestPhoneDiscardMigrateConferenceCarriesSlug(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)

View file

@ -318,7 +318,6 @@ func (r *Router) sendSuggestedProfilePhotoMessage(ctx context.Context, userID, t
if err != nil {
return domain.SendPrivateTextResult{}, err
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
photoCopy := photo
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
@ -333,7 +332,7 @@ func (r *Router) sendSuggestedProfilePhotoMessage(ctx context.Context, userID, t
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})

View file

@ -14,7 +14,7 @@ func (r *Router) pushUserMessage(ctx context.Context, userID int64, logMessage s
}
sessionID, _ := SessionIDFrom(ctx)
if timeout := r.cfg.OutboundPushTimeout; timeout > 0 {
authKeyID, _ := AuthKeyIDFrom(ctx)
authKeyID := rawAuthKeyIDForOrigin(ctx)
if scoped, ok := r.deps.Sessions.(ScopedBestEffortSessionBinder); ok {
if sent, err := scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg, timeout); err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err))
@ -33,7 +33,7 @@ func (r *Router) pushUserMessage(ctx context.Context, userID int64, logMessage s
}
}
if scoped, ok := r.scopedSessions(); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
authKeyID := rawAuthKeyIDForOrigin(ctx)
if sent, err := scoped.PushToUserExceptAuthKeySession(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg); err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err))
return sent
@ -58,7 +58,7 @@ func (r *Router) pushUserMessageTransient(ctx context.Context, userID int64, log
}
if transient, ok := r.deps.Sessions.(TransientSessionBinder); ok {
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
authKeyID := rawAuthKeyIDForOrigin(ctx)
sent, err := transient.PushToUserTransientExceptAuthKeySession(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg, r.cfg.OutboundPushTimeout)
if err != nil {
r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err))

View file

@ -2,14 +2,24 @@ package rpc
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const sendRateLimitKeyPrefix = "messages:send:"
const (
authCodePhoneRateLimitKeyPrefix = "auth:code:phone-sha256:"
authCodeAuthKeyRateLimitKeyPrefix = "auth:code:raw-auth-key:"
defaultAuthCodeRateWindow = 10 * time.Minute
)
const (
channelDifferenceRateLimitKeyPrefix = "updates:channeldifference:"
peerDialogsRateLimitKeyPrefix = "messages:peerdialogs:"
@ -65,3 +75,55 @@ func (r *Router) checkSendRateLimit(ctx context.Context, userID int64, cost int)
r.metrics().MessageRateLimited(retryAfter)
return floodWaitErr(retryAfter)
}
// checkAuthCodeRateLimit protects the unauthenticated code-issuance path before
// any account lookup or durable 777000 write. Existing and unknown phone numbers
// therefore consume identical budgets and cannot be distinguished through the
// limiter. Plaintext phone numbers are never used as limiter keys or log fields.
func (r *Router) checkAuthCodeRateLimit(ctx context.Context, phone string) error {
if r.deps.Limiter == nil {
return nil
}
normalizedPhone := domain.NormalizePhone(phone)
if !domain.ValidPhone(normalizedPhone) {
return phoneNumberInvalidErr()
}
window := r.cfg.AuthCodeRateWindow
if window <= 0 {
window = defaultAuthCodeRateWindow
}
// Check the connection/auth-key budget first. If that dimension is already
// blocked, changing phone strings cannot create one phone-digest Redis key
// per attempt and bypass the intended cardinality bound.
if limit := r.cfg.AuthCodeAuthKeyRateLimit; limit > 0 {
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok && rawAuthKeyID != ([8]byte{}) {
if err := r.checkAuthCodeRateLimitKey(ctx, authCodeAuthKeyRateLimitKeyPrefix+hex.EncodeToString(rawAuthKeyID[:]), limit, window, "raw_auth_key"); err != nil {
return err
}
}
}
if limit := r.cfg.AuthCodePhoneRateLimit; limit > 0 {
digest := sha256.Sum256([]byte(normalizedPhone))
if err := r.checkAuthCodeRateLimitKey(ctx, authCodePhoneRateLimitKeyPrefix+hex.EncodeToString(digest[:]), limit, window, "phone_digest"); err != nil {
return err
}
}
return nil
}
func (r *Router) checkAuthCodeRateLimitKey(ctx context.Context, key string, limit int, window time.Duration, dimension string) error {
allowed, retryAfter, err := r.deps.Limiter.AllowN(ctx, key, 1, limit, window)
if err != nil {
return internalErr()
}
if allowed {
return nil
}
if retryAfter <= 0 {
retryAfter = 1
}
r.log.Debug("auth code issuance rate limited",
zap.String("dimension", dimension),
zap.Int("retry_after", retryAfter))
return floodWaitErr(retryAfter)
}

View file

@ -0,0 +1,137 @@
package rpc
import (
"encoding/binary"
"fmt"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
appfiles "telesrv/internal/app/files"
"telesrv/internal/domain"
)
const tlVectorTypeID = uint32(0x1cb5c415)
type requestVectorPolicy struct {
vectorOffset int
max int
minElemBytes int
tooLong func() error
}
// requestVectorPolicies mirrors limits already enforced by typed handlers, but does so before
// gotd's generated decoder materializes attacker-controlled interface slices. users.getUsers is
// the one newly introduced cap: TDesktop's four current call sites all send exactly one user.
var requestVectorPolicies = map[uint32]requestVectorPolicy{
tg.UsersGetUsersRequestTypeID: {vectorOffset: 4, max: 100, minElemBytes: 4, tooLong: inputRequestTooLongErr},
tg.UsersGetRequirementsToContactRequestTypeID: {vectorOffset: 4, max: maxRequirementsToContactUsers, minElemBytes: 4, tooLong: limitInvalidErr},
tg.ContactsImportContactsRequestTypeID: {vectorOffset: 4, max: maxContactImportBatch, minElemBytes: 4, tooLong: limitInvalidErr},
tg.ContactsDeleteContactsRequestTypeID: {vectorOffset: 4, max: maxContactDeleteBatch, minElemBytes: 4, tooLong: limitInvalidErr},
tg.ContactsEditCloseFriendsRequestTypeID: {vectorOffset: 4, max: maxCloseFriendsCount, minElemBytes: 8, tooLong: limitInvalidErr},
tg.ContactsSetBlockedRequestTypeID: {vectorOffset: 8, max: maxContactSetBlocked, minElemBytes: 4, tooLong: limitInvalidErr},
tg.MessagesGetMessagesRequestTypeID: {vectorOffset: 4, max: maxGetMessagesIDs, minElemBytes: 4, tooLong: limitInvalidErr},
tg.MessagesGetChatsRequestTypeID: {vectorOffset: 4, max: maxGetMessagesIDs, minElemBytes: 8, tooLong: limitInvalidErr},
tg.MessagesGetPeerDialogsRequestTypeID: {vectorOffset: 4, max: maxDialogInputPeers, minElemBytes: 4, tooLong: limitInvalidErr},
tg.MessagesReadMessageContentsRequestTypeID: {vectorOffset: 4, max: maxGetMessagesIDs, minElemBytes: 4, tooLong: limitInvalidErr},
tg.MessagesGetCustomEmojiDocumentsRequestTypeID: {vectorOffset: 4, max: maxEmojiDocuments, minElemBytes: 8, tooLong: limitInvalidErr},
tg.MessagesDeleteMessagesRequestTypeID: {vectorOffset: 8, max: domain.MaxDeleteMessageIDs, minElemBytes: 4, tooLong: limitInvalidErr},
tg.MessagesCreateChatRequestTypeID: {vectorOffset: 8, max: 200, minElemBytes: 4, tooLong: limitInvalidErr},
tg.ChannelsGetChannelsRequestTypeID: {vectorOffset: 4, max: maxGetMessagesIDs, minElemBytes: 4, tooLong: limitInvalidErr},
}
func preflightRPCRequest(id uint32, b *bin.Buffer) error {
if b == nil {
return inputRequestInvalidErr()
}
if policy, ok := requestVectorPolicies[id]; ok {
if err := preflightFixedVector(b.Buf, policy); err != nil {
return err
}
}
switch id {
case tg.UploadSaveFilePartRequestTypeID:
return preflightUploadPart(b.Buf, 16, false)
case tg.UploadSaveBigFilePartRequestTypeID:
return preflightUploadPart(b.Buf, 20, true)
default:
return nil
}
}
func preflightFixedVector(raw []byte, policy requestVectorPolicy) error {
if policy.vectorOffset < 4 || policy.minElemBytes <= 0 || len(raw) < policy.vectorOffset+8 {
return inputRequestInvalidErr()
}
if binary.LittleEndian.Uint32(raw[policy.vectorOffset:]) != tlVectorTypeID {
return inputRequestInvalidErr()
}
count := int64(int32(binary.LittleEndian.Uint32(raw[policy.vectorOffset+4:])))
if count < 0 {
return inputRequestInvalidErr()
}
remaining := int64(len(raw) - policy.vectorOffset - 8)
// Check the cheapest possible encoding before the policy cap. A forged MaxInt32 count with
// a truncated body is malformed, not merely a large valid request, and is rejected O(1).
if count > remaining/int64(policy.minElemBytes) {
return inputRequestInvalidErr()
}
if count > int64(policy.max) {
if policy.tooLong != nil {
return policy.tooLong()
}
return inputRequestTooLongErr()
}
return nil
}
func preflightUploadPart(raw []byte, bytesOffset int, big bool) error {
if big {
if len(raw) < 20 {
return inputRequestInvalidErr()
}
totalParts := int32(binary.LittleEndian.Uint32(raw[16:20]))
if totalParts <= 0 || totalParts > appfiles.MaxUploadParts {
return filePartInvalidErr()
}
}
n, encoded, err := tlBytesSizeAt(raw, bytesOffset)
if err != nil {
return inputRequestInvalidErr()
}
if encoded != len(raw)-bytesOffset {
return inputRequestInvalidErr()
}
if n > appfiles.MaxUploadPartBytes {
return filePartTooBigErr()
}
return nil
}
// tlBytesSizeAt parses a TL bytes prefix without copying the payload. encoded includes prefix,
// payload and 4-byte padding.
func tlBytesSizeAt(raw []byte, offset int) (n, encoded int, err error) {
if offset < 0 || offset >= len(raw) {
return 0, 0, fmt.Errorf("bytes prefix out of range")
}
first := raw[offset]
prefix := 1
switch {
case first < 254:
n = int(first)
case first == 254:
if len(raw)-offset < 4 {
return 0, 0, fmt.Errorf("truncated long bytes prefix")
}
n = int(raw[offset+1]) | int(raw[offset+2])<<8 | int(raw[offset+3])<<16
prefix = 4
default:
return 0, 0, fmt.Errorf("invalid bytes prefix")
}
total := prefix + n
padding := (4 - total%4) % 4
if total > len(raw)-offset-padding {
return 0, 0, fmt.Errorf("truncated bytes payload")
}
return n, total + padding, nil
}

View file

@ -0,0 +1,134 @@
package rpc
import (
"context"
"encoding/binary"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appfiles "telesrv/internal/app/files"
)
func TestRequestVectorPreflightMirrorsHandlerCaps(t *testing.T) {
for id, policy := range requestVectorPolicies {
id, policy := id, policy
t.Run(tlTypeName(id), func(t *testing.T) {
atCap := fixedVectorRequest(id, policy, policy.max)
if err := preflightRPCRequest(id, &bin.Buffer{Buf: atCap}); err != nil {
t.Fatalf("cap=%d rejected: %v", policy.max, err)
}
over := fixedVectorRequest(id, policy, policy.max+1)
err := preflightRPCRequest(id, &bin.Buffer{Buf: over})
if err == nil {
t.Fatalf("cap+1=%d accepted", policy.max+1)
}
want := "LIMIT_INVALID"
if id == tg.UsersGetUsersRequestTypeID {
want = "INPUT_REQUEST_TOO_LONG"
}
if !tgerr.Is(err, want) {
t.Fatalf("cap+1 error = %v, want %s", err, want)
}
})
}
}
func TestRequestVectorPreflightRejectsForgedCountInConstantSpace(t *testing.T) {
policy := requestVectorPolicies[tg.UsersGetUsersRequestTypeID]
raw := fixedVectorRequest(tg.UsersGetUsersRequestTypeID, policy, 0)
binary.LittleEndian.PutUint32(raw[policy.vectorOffset+4:], uint32(0x7fffffff))
if err := preflightRPCRequest(tg.UsersGetUsersRequestTypeID, &bin.Buffer{Buf: raw}); !tgerr.Is(err, "INPUT_REQUEST_INVALID") {
t.Fatalf("forged count error = %v, want INPUT_REQUEST_INVALID", err)
}
}
func TestRequestVectorPreflightRunsAfterWrapperBeforeTypedDecode(t *testing.T) {
ids := make([]tg.InputUserClass, 101)
for i := range ids {
ids[i] = &tg.InputUserSelf{}
}
wrapped := &tg.InvokeWithLayerRequest{Layer: 227, Query: &tg.UsersGetUsersRequest{ID: ids}}
var body bin.Buffer
if err := wrapped.Encode(&body); err != nil {
t.Fatalf("encode wrapper: %v", err)
}
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
_, err := r.Dispatch(context.Background(), [8]byte{1}, 1, &body)
if !tgerr.Is(err, "INPUT_REQUEST_TOO_LONG") {
t.Fatalf("wrapped oversized users.getUsers error = %v, want INPUT_REQUEST_TOO_LONG", err)
}
}
func TestUploadPartPreflightBeforeBytesDecode(t *testing.T) {
for _, tc := range []struct {
name string
id uint32
offset int
big bool
parts int
size int
want string
truncateBy int
}{
{name: "small_at_cap", id: tg.UploadSaveFilePartRequestTypeID, offset: 16, size: appfiles.MaxUploadPartBytes},
{name: "small_over_cap", id: tg.UploadSaveFilePartRequestTypeID, offset: 16, size: appfiles.MaxUploadPartBytes + 1, want: "FILE_PART_TOO_BIG"},
{name: "big_at_cap", id: tg.UploadSaveBigFilePartRequestTypeID, offset: 20, big: true, parts: appfiles.MaxUploadParts, size: appfiles.MaxUploadPartBytes},
{name: "big_parts_over_cap", id: tg.UploadSaveBigFilePartRequestTypeID, offset: 20, big: true, parts: appfiles.MaxUploadParts + 1, size: 1, want: "FILE_PART_INVALID"},
{name: "truncated", id: tg.UploadSaveFilePartRequestTypeID, offset: 16, size: 1024, truncateBy: 1, want: "INPUT_REQUEST_INVALID"},
} {
t.Run(tc.name, func(t *testing.T) {
raw := uploadPartRequest(tc.id, tc.offset, tc.parts, tc.size)
if tc.truncateBy > 0 {
raw = raw[:len(raw)-tc.truncateBy]
}
err := preflightRPCRequest(tc.id, &bin.Buffer{Buf: raw})
if tc.want == "" {
if err != nil {
t.Fatalf("preflight: %v", err)
}
return
}
if !tgerr.Is(err, tc.want) {
t.Fatalf("error = %v, want %s", err, tc.want)
}
})
}
}
func fixedVectorRequest(id uint32, policy requestVectorPolicy, count int) []byte {
raw := make([]byte, policy.vectorOffset+8+count*policy.minElemBytes)
binary.LittleEndian.PutUint32(raw[0:4], id)
binary.LittleEndian.PutUint32(raw[policy.vectorOffset:policy.vectorOffset+4], tlVectorTypeID)
binary.LittleEndian.PutUint32(raw[policy.vectorOffset+4:policy.vectorOffset+8], uint32(count))
return raw
}
func uploadPartRequest(id uint32, offset, parts, size int) []byte {
raw := make([]byte, offset)
binary.LittleEndian.PutUint32(raw[:4], id)
if offset == 20 {
binary.LittleEndian.PutUint32(raw[16:20], uint32(parts))
}
prefix := 1
if size >= 254 {
prefix = 4
}
total := prefix + size
padding := (4 - total%4) % 4
start := len(raw)
raw = append(raw, make([]byte, total+padding)...)
if prefix == 1 {
raw[start] = byte(size)
} else {
raw[start] = 254
raw[start+1] = byte(size)
raw[start+2] = byte(size >> 8)
raw[start+3] = byte(size >> 16)
}
return raw
}

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
@ -53,6 +54,12 @@ type Config struct {
OutboundPushTimeout time.Duration
SendRateLimit int
SendRateWindow time.Duration
// AuthCode*RateLimit protects the unauthenticated sendCode/resendCode write path.
// The phone budget is keyed by SHA-256(normalized phone), never by the plaintext phone;
// the second budget is keyed by the physical connection's raw auth_key_id.
AuthCodePhoneRateLimit int
AuthCodeAuthKeyRateLimit int
AuthCodeRateWindow time.Duration
// CatchupRateLimit/CatchupRateWindow 限制 difference 类 catch-up RPCgetChannelDifference /
// getPeerDialogs的每用户频率设计 Phase 2 / §10.3nudge 被消费后客户端会触发这两类
// catch-up放开大群 nudge 全速前需 FLOOD_WAIT 兜底防风暴打爆 PG。两类各自独立计数、共用同一
@ -216,6 +223,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
// 再按 TypeID 路由到 typed handler。满足 mtprotoedge.RPCHandler。
func (r *Router) Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) {
preStart := r.clock.Now()
ctx = withInboundRPCBytes(ctx, b.Len())
ctx = WithRawAuthKeyID(ctx, authKeyID)
effectiveAuthKeyID, err := r.effectiveAuthKeyID(ctx, authKeyID, sessionID)
if err != nil {
@ -579,8 +587,8 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En
if err != nil {
return nil, fmt.Errorf("decode invokeAfterMsgs msg_ids: %w", err)
}
if msgIDs > maxInvokeAfterMsgIDs {
return nil, fmt.Errorf("decode invokeAfterMsgs msg_ids: too many ids %d", msgIDs)
if msgIDs < 0 || msgIDs > maxInvokeAfterMsgIDs {
return nil, fmt.Errorf("decode invokeAfterMsgs msg_ids: invalid count %d", msgIDs)
}
for i := 0; i < msgIDs; i++ {
if _, err := b.Long(); err != nil {
@ -644,6 +652,16 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En
}
}
}
knownRequest, structuralErr := layerwire.ValidateRoutableRequest(b.Buf)
if !knownRequest {
if structuralErr != nil {
return nil, mapLayerwirePreflightError(structuralErr)
}
// Unknown methods are opaque after the bounded/alignment check above: no generated
// decoder will touch their body. Route them directly to the compatibility fallback
// so every unknown constructor is traced, including pre-login probes.
return r.fallback(ctx, b)
}
if r.deps.Auth != nil {
if _, ok := UserIDFrom(ctx); !ok && !rpcAllowedWithoutAuthorization(id) {
fields := append([]zap.Field{
@ -654,6 +672,16 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En
return nil, authKeyUnregisteredErr()
}
}
if err := preflightRPCRequest(id, b); err != nil {
return nil, err
}
// Run the same allocation-free schema walker used by layer aliases/DrKLO transforms on
// every canonical request before gotd's generated decoder materializes vectors/bytes.
// Method-specific caps above preserve exact existing RPC errors; this generic budget
// closes variable-offset/nested-object paths and malformed constructor recursion.
if structuralErr != nil {
return nil, mapLayerwirePreflightError(structuralErr)
}
// 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates
// 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复:
// 它重连后不重建同步基线pts 在进程内存里),只发普通业务请求,置位
@ -682,6 +710,13 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En
}
}
func mapLayerwirePreflightError(err error) error {
if errors.Is(err, layerwire.ErrResourceLimit) {
return inputRequestTooLongErr()
}
return inputRequestInvalidErr()
}
func tlTypeName(id uint32) string {
tlTypeNamesOnce.Do(func() {
names := tg.NamesMap()

View file

@ -6,6 +6,7 @@ import (
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
@ -13,13 +14,35 @@ import (
"time"
)
// authBindingCaptureSessions keeps session authorization state separate from the target of an
// asynchronous presence push. The broad captureSessions fake intentionally records the latest
// PushToUser target in userID, which is useful to most RPC tests but can race a stale-auth-key
// assertion and make an old presence echo look like the session was rebound.
type authBindingCaptureSessions struct {
*captureSessions
}
func newAuthBindingCaptureSessions() *authBindingCaptureSessions {
return &authBindingCaptureSessions{captureSessions: &captureSessions{}}
}
func (s *authBindingCaptureSessions) PushToUserExceptSession(_ context.Context, userID, _ int64, t proto.MessageType, msg bin.Encoder) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.messageType = t
s.message = msg
s.userMessage = msg
s.pushUserIDs = append(s.pushUserIDs, userID)
return 1, nil
}
func TestDispatchPromotesNegativeSessionCacheFromPositiveAuthCache(t *testing.T) {
authKeyID := [8]byte{0x91, 0x91, 0x91, 0x91, 0x91, 0x91, 0x91, 0x91}
const (
sessionID = int64(300)
userID = int64(1000000001)
)
sessions := &captureSessions{}
sessions := newAuthBindingCaptureSessions()
sessions.BindAuthKey(sessionID, authKeyID)
sessions.BindUser(sessionID, 0)
auth := &captureAuthService{}
@ -55,7 +78,7 @@ func TestDispatchPromotesNegativeSessionCacheFromPositiveAuthCache(t *testing.T)
func TestBindTempAuthKeyClearsNegativeUserCache(t *testing.T) {
var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55}
var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}
sessions := &captureSessions{}
sessions := newAuthBindingCaptureSessions()
auth := &captureAuthService{}
r := New(Config{}, Deps{
Auth: auth,
@ -89,7 +112,7 @@ func TestBindTempAuthKeyClearsNegativeUserCache(t *testing.T) {
func TestDispatchRevalidatesCachedTempAuthKeyBinding(t *testing.T) {
var tempAuthKeyID = [8]byte{0x65, 0x65, 0x65, 0x65, 0x65, 0x65, 0x65, 0x65}
var permAuthKeyID = [8]byte{0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21}
sessions := &captureSessions{}
sessions := newAuthBindingCaptureSessions()
auth := &captureAuthService{
resolvedAuthKeyID: permAuthKeyID,
hasResolved: true,
@ -142,7 +165,7 @@ func TestDispatchRevalidatesCachedTempAuthKeyBinding(t *testing.T) {
func TestDispatchUsesCachedTempAuthKeyUserUntilWriteSideInvalidation(t *testing.T) {
var tempAuthKeyID = [8]byte{0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66}
var permAuthKeyID = [8]byte{0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22}
sessions := &captureSessions{}
sessions := newAuthBindingCaptureSessions()
auth := &captureAuthService{
resolvedAuthKeyID: permAuthKeyID,
hasResolved: true,

View file

@ -15,8 +15,10 @@ type captureUpdates struct {
cleared bool
date int
events []domain.UpdateEvent
excludeAuthKeyID [8]byte
excludeSessionID int64
reliableDispatch bool
difference *domain.UpdateDifference
}
func (s *captureUpdates) UsesReliableDispatch() bool {
@ -59,6 +61,9 @@ func (s *captureUpdates) AcknowledgeCurrentState(_ context.Context, authKeyID [8
func (s *captureUpdates) GetDifference(_ context.Context, authKeyID [8]byte, userID int64, _ domain.UpdateState) (domain.UpdateDifference, error) {
s.authKeyID = authKeyID
s.userID = userID
if s.difference != nil {
return *s.difference, nil
}
return domain.UpdateDifference{State: s.state}, nil
}
@ -90,8 +95,13 @@ func (s *captureUpdates) PublishNewMessage(_ context.Context, userID int64, msg
return event, st, nil
}
func (s *captureUpdates) RecordStory(_ context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
func (s *captureUpdates) captureExclude(excludeAuthKeyID [8]byte, excludeSessionID int64) {
s.excludeAuthKeyID = excludeAuthKeyID
s.excludeSessionID = excludeSessionID
}
func (s *captureUpdates) RecordStory(_ context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventStory,
Peer: story.Owner,
@ -109,10 +119,10 @@ func (s *captureUpdates) RecordStoryFanout(_ context.Context, userID int64, stor
})
}
func (s *captureUpdates) RecordReadHistory(_ context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
func (s *captureUpdates) RecordReadHistory(_ context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.authKeyID = authKeyID
s.userID = userID
s.excludeSessionID = excludeSessionID
s.captureExclude(excludeAuthKeyID, excludeSessionID)
event := domain.UpdateEvent{
Type: domain.UpdateEventReadHistoryInbox,
Pts: s.state.Pts,
@ -127,8 +137,8 @@ func (s *captureUpdates) RecordReadHistory(_ context.Context, authKeyID [8]byte,
return event, s.state, nil
}
func (s *captureUpdates) RecordReadStories(_ context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordReadStories(_ context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventReadStories,
Peer: read.Peer,
@ -136,8 +146,8 @@ func (s *captureUpdates) RecordReadStories(_ context.Context, authKeyID [8]byte,
})
}
func (s *captureUpdates) RecordSentStoryReaction(_ context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordSentStoryReaction(_ context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventSentStoryReaction,
Peer: reaction.Peer,
@ -147,8 +157,8 @@ func (s *captureUpdates) RecordSentStoryReaction(_ context.Context, authKeyID [8
})
}
func (s *captureUpdates) RecordNewStoryReaction(_ context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordNewStoryReaction(_ context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, ownerUserID, domain.UpdateEvent{
Type: domain.UpdateEventNewStoryReaction,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reaction.ViewerID},
@ -158,8 +168,8 @@ func (s *captureUpdates) RecordNewStoryReaction(_ context.Context, authKeyID [8]
})
}
func (s *captureUpdates) RecordQuickReplyMutation(_ context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordQuickReplyMutation(_ context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
event := domain.UpdateEvent{
Date: mutation.Date,
QuickReplies: append([]domain.QuickReply(nil), mutation.List.QuickReplies...),
@ -183,78 +193,78 @@ func (s *captureUpdates) RecordQuickReplyMutation(_ context.Context, authKeyID [
return s.recordCapturedEvent(authKeyID, userID, event)
}
func (s *captureUpdates) RecordChannelState(_ context.Context, authKeyID [8]byte, userID, channelID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordChannelState(_ context.Context, authKeyID [8]byte, userID, channelID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventChannelState, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}})
}
func (s *captureUpdates) RecordContactsReset(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordContactsReset(_ context.Context, authKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventContactsReset})
}
func (s *captureUpdates) RecordDraftMessage(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordDraftMessage(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDraftMessage, Peer: peer, MaxID: topMsgID})
}
func (s *captureUpdates) RecordDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogPinned, Peer: peer, Bool: pinned, FolderID: folderID})
}
func (s *captureUpdates) RecordPinnedDialogs(_ context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordPinnedDialogs(_ context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPinnedDialogs, Peers: append([]domain.Peer(nil), order...), FolderID: folderID})
}
func (s *captureUpdates) RecordSavedDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordSavedDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventSavedDialogPinned, Peer: peer, Bool: pinned})
}
func (s *captureUpdates) RecordPinnedSavedDialogs(_ context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordPinnedSavedDialogs(_ context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPinnedSavedDialogs, Peers: append([]domain.Peer(nil), order...)})
}
func (s *captureUpdates) RecordDialogUnreadMark(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordDialogUnreadMark(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogUnreadMark, Peer: peer, Bool: unread})
}
func (s *captureUpdates) RecordPeerSettings(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordPeerSettings(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPeerSettings, Peer: peer, Settings: settings})
}
func (s *captureUpdates) RecordPeerStoryBlocked(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordPeerStoryBlocked(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPeerStoryBlocked, Peer: peer, Bool: blocked})
}
func (s *captureUpdates) RecordDialogFilter(_ context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordDialogFilter(_ context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilter, FilterID: folderID, DialogFilter: folder})
}
func (s *captureUpdates) RecordDialogFilterOrder(_ context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordDialogFilterOrder(_ context.Context, authKeyID [8]byte, userID int64, order []int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilterOrder, FilterOrder: append([]int(nil), order...)})
}
func (s *captureUpdates) RecordDialogFiltersReload(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordDialogFiltersReload(_ context.Context, authKeyID [8]byte, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilters})
}
func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)})
}
func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelAvailable,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
@ -262,8 +272,8 @@ func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authK
})
}
func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelViewForum,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
@ -271,8 +281,8 @@ func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, aut
})
}
func (s *captureUpdates) RecordChannelDiscussionInbox(_ context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.excludeSessionID = excludeSessionID
func (s *captureUpdates) RecordChannelDiscussionInbox(_ context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
s.captureExclude(excludeAuthKeyID, excludeSessionID)
return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventReadChannelDiscussionInbox,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},

View file

@ -22,19 +22,21 @@ const maxContactVcardLength = 8192
// outgoingSend 是 sendOutgoing 的入参:一条已校验的出站消息。
type outgoingSend struct {
randomID int64
message string
entities []tg.MessageEntityClass
media *domain.MessageMedia
silent bool
noforwards bool
replyToInput tg.InputReplyToClass
sendAsInput tg.InputPeerClass
replyTo *domain.MessageReply
replyToReady bool
sendAs *domain.Peer
sendAsReady bool
clearDraft bool
randomID int64
idempotencyFingerprint []byte
idempotencyPreflighted bool
message string
entities []tg.MessageEntityClass
media *domain.MessageMedia
silent bool
noforwards bool
replyToInput tg.InputReplyToClass
sendAsInput tg.InputPeerClass
replyTo *domain.MessageReply
replyToReady bool
sendAs *domain.Peer
sendAsReady bool
clearDraft bool
// replyMarkup 是 bot inline keyboard已解析+校验;非 bot 恒 nil
replyMarkup *domain.MessageReplyMarkup
viaBotID int64
@ -76,24 +78,26 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
return nil, false, err
}
res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{
UserID: userID,
ChannelID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntitiesForViewer(userID, p.entities),
Media: p.media,
MentionUserIDs: mentionUserIDs,
SkipRecipientLookup: true,
PostAuthor: r.channelPostAuthorName(ctx, userID),
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
ViaBotID: p.viaBotID,
GroupedID: p.groupedID,
ReplyMarkup: p.replyMarkup,
RichMessage: p.richMessage,
SendAs: sendAs,
Date: int(r.clock.Now().Unix()),
UserID: userID,
ChannelID: peer.ID,
RandomID: p.randomID,
IdempotencyFingerprint: p.idempotencyFingerprint,
IdempotencyPreflighted: p.idempotencyPreflighted,
Message: p.message,
Entities: domainMessageEntitiesForViewer(userID, p.entities),
Media: p.media,
MentionUserIDs: mentionUserIDs,
SkipRecipientLookup: true,
PostAuthor: r.channelPostAuthorName(ctx, userID),
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
ViaBotID: p.viaBotID,
GroupedID: p.groupedID,
ReplyMarkup: p.replyMarkup,
RichMessage: p.richMessage,
SendAs: sendAs,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, false, channelInvalidErr(err)
@ -112,7 +116,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
// 频道链接预览 pending 占位:带外解析并就地替换(异步,不阻塞发送 echo
r.maybeEnqueueWebPageResolve(userID, peer, res.Message.ID, res.Message.Media)
}
if p.clearDraft {
if p.clearDraft && !res.Duplicate {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return updates, res.Duplicate, nil
@ -143,26 +147,30 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
return nil, false, err
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
// outbox 的排除键定位的是发起 RPC 的物理连接PFS temp key 绑定后
// AuthKeyIDFrom 是业务视角 perm key不能用它代替连接实际 raw key。
authKeyID := rawAuthKeyIDForOrigin(ctx)
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntitiesForViewer(userID, p.entities),
Media: p.media,
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
ReplyMarkup: p.replyMarkup,
RichMessage: p.richMessage,
ViaBotID: p.viaBotID,
GroupedID: p.groupedID,
Effect: p.effect,
SenderUserID: userID,
RecipientUserID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntitiesForViewer(userID, p.entities),
Media: p.media,
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
IdempotencyFingerprint: p.idempotencyFingerprint,
IdempotencyPreflighted: p.idempotencyPreflighted,
ReplyMarkup: p.replyMarkup,
RichMessage: p.richMessage,
ViaBotID: p.viaBotID,
GroupedID: p.groupedID,
Effect: p.effect,
})
if err != nil {
fields := append(r.contextLogFields(ctx),
@ -179,9 +187,13 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
r.log.Warn("messages.sendMessage private store failed", fields...)
return nil, false, messageSendErr(err)
}
users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
if p.clearDraft {
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
users = r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
chats = r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
}
if p.clearDraft && !res.Duplicate {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
if !res.Duplicate {
@ -189,7 +201,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
r.maybeEnqueueWebPageResolve(userID, peer, res.SenderMessage.ID, res.SenderMessage.Media)
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
}
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, p.randomID, true, users, chats), res.Duplicate, nil
return tgPrivateSendResultUpdates(res, p.randomID, true, users, chats), res.Duplicate, nil
}
// onMessagesUploadMedia 解析 InputMedia上传或引用返回可复用的 tg.MessageMedia。
@ -244,6 +256,10 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
case *tg.InputMediaEmpty, *tg.InputMediaWebPage:
return r.onMessagesSendMessage(ctx, sendMessageRequestFromSendMedia(req))
}
// 指纹是幂等校验元数据,不能让一个本应由 media resolver 映射成
// MEDIA_INVALID 的畸形 input 因 Encode 失败提前变成 INTERNAL。合法请求会得到
// 原始 TL 指纹;编码失败则留空,由 store 使用 domain fallback。
idempotencyFingerprint, _ := sendMediaIdempotencyFingerprint(req)
// 媒体 caption 里的链接/@mention/#hashtag 等同样补自动高亮实体(客户端未带时)。
req.Entities = augmentAutoEntities(req.Message, req.Entities)
userID, _, err := r.currentUserID(ctx)
@ -253,6 +269,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if userID == 0 {
return nil, peerIDInvalidErr()
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
return nil, err
}
if replay.found {
return r.outgoingReplayUpdates(ctx, userID, peer, req.RandomID, replay), nil
}
// 消息特效:仅接受 catalog 内的合法 effect id非法 id → EFFECT_ID_INVALID官方行为
if r.messageEffectInvalid(ctx, req.Effect) {
return nil, effectIDInvalidErr()
@ -260,7 +287,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
return nil, err
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
@ -281,29 +308,33 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: req.Message,
entities: req.Entities,
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
randomID: req.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked,
message: req.Message,
entities: req.Entities,
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
}
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: req.Message,
entities: req.Entities,
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
effect: req.Effect,
randomID: req.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked,
message: req.Message,
entities: req.Entities,
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
effect: req.Effect,
})
if err != nil {
return nil, err
@ -311,7 +342,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
return updates, nil
}
// onMessagesSendMultiMedia 发送相册(多条媒体)。本阶段不绑定 grouped_id各条作为独立消息呈现
// onMessagesSendMultiMedia 发送相册(多条媒体),并在解析媒体前持久预留 grouped_id
func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesSendMultiMediaRequest) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -323,14 +354,20 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems {
return nil, limitInvalidErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
randomIDs := make(map[int64]struct{}, len(req.MultiMedia))
reservationItems := make([]domain.AlbumGroupReservationItem, 0, len(req.MultiMedia))
for _, item := range req.MultiMedia {
if item.RandomID == 0 {
return nil, randomIDEmptyErr()
}
if _, duplicate := randomIDs[item.RandomID]; duplicate {
return nil, randomIDDuplicateErr()
}
randomIDs[item.RandomID] = struct{}{}
if utf8.RuneCountInString(item.Message) > maxSendMessageTextLength {
return nil, mediaCaptionTooLongErr()
}
@ -340,18 +377,69 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if item.Media == nil {
return nil, mediaInvalidErr()
}
intentHash, fingerprintErr := sendMultiMediaItemIdempotencyFingerprint(req, item)
if fingerprintErr != nil {
// 畸形 InputMedia 的 TL 编码失败不能变成 INTERNAL保持 media 输入错误语义。
return nil, mediaInvalidErr()
}
reservationItems = append(reservationItems, domain.AlbumGroupReservationItem{
RandomID: item.RandomID,
IntentHash: intentHash,
})
}
if err := r.checkSendRateLimit(ctx, userID, len(req.MultiMedia)); err != nil {
replays := make([]outgoingReplayLookup, len(req.MultiMedia))
absentCount := 0
for i, item := range req.MultiMedia {
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, item.RandomID, reservationItems[i].IntentHash)
if err != nil {
return nil, err
}
replays[i] = replay
if !replay.found {
absentCount++
}
}
if absentCount == 0 {
results := make([]tg.UpdatesClass, 0, len(req.MultiMedia))
for i, item := range req.MultiMedia {
results = append(results, r.outgoingReplayUpdates(ctx, userID, peer, item.RandomID, replays[i]))
}
return combineSendUpdates(results), nil
}
if err := r.checkSendRateLimit(ctx, userID, absentCount); err != nil {
return nil, err
}
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
combined := make([]tg.UpdateClass, 0, len(req.MultiMedia)*2)
usersByID := map[int64]tg.UserClass{}
chatsByID := map[int64]tg.ChatClass{}
date := 0
// 整个 album 共享一个 grouped_id客户端据此把各条渲染成一个相册组。
groupedID := randomNonZeroInt64()
// 必须在 resolveInputMedia 或发送任何 item 之前原子预留:首次请求若在第 N 条
// 失败,客户端只重试失败子集时仍从已绑定 random_id 恢复整包 grouped_id。
groupedID, err := r.reserveAlbumGroup(ctx, userID, peer, reservationItems)
if err != nil {
return nil, err
}
for _, replay := range replays {
if replay.found {
messageGroupedID := replay.private.SenderMessage.GroupedID
if peer.Type == domain.PeerTypeChannel {
messageGroupedID = replay.channel.Message.GroupedID
}
if messageGroupedID != groupedID {
return nil, internalErr()
}
}
}
results := make([]tg.UpdatesClass, 0, len(req.MultiMedia))
clearDraftPending := req.ClearDraft
for i, item := range req.MultiMedia {
idempotencyFingerprint := reservationItems[i].IntentHash
if replays[i].found {
results = append(results, r.outgoingReplayUpdates(ctx, userID, peer, item.RandomID, replays[i]))
continue
}
media, err := r.resolveInputMedia(ctx, userID, item.Media)
if err != nil {
return nil, err
@ -360,49 +448,35 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
return nil, mediaInvalidErr()
}
p := outgoingSend{
randomID: item.RandomID,
message: item.Message,
entities: augmentAutoEntities(item.Message, item.Entities),
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft && i == 0,
groupedID: groupedID,
randomID: item.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replays[i].checked,
message: item.Message,
entities: augmentAutoEntities(item.Message, item.Entities),
media: media,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: clearDraftPending,
groupedID: groupedID,
}
var result tg.UpdatesClass
duplicate := false
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
result, err = r.scheduleOutgoing(ctx, userID, peer, p, req.ScheduleDate, 0)
} else {
result, _, err = r.sendOutgoing(ctx, userID, peer, p)
result, duplicate, err = r.sendOutgoing(ctx, userID, peer, p)
}
if err != nil {
return nil, err
}
if upd, ok := result.(*tg.Updates); ok {
combined = append(combined, upd.Updates...)
for _, u := range upd.Users {
if id := userClassID(u); id != 0 {
usersByID[id] = u
}
}
for _, c := range upd.Chats {
if id := chatClassID(c); id != 0 {
chatsByID[id] = c
}
}
if upd.Date != 0 {
date = upd.Date
}
if p.clearDraft && !duplicate {
clearDraftPending = false
}
results = append(results, result)
}
return &tg.Updates{
Updates: combined,
Users: mapValuesUsers(usersByID),
Chats: mapValuesChats(chatsByID),
Date: date,
}, nil
return combineSendUpdates(results), nil
}
// resolveInputMedia 把 tg.InputMedia 解析为 domain.MessageMedia上传则落库引用则加载

View file

@ -836,6 +836,126 @@ func TestSendMediaPrivateSticker(t *testing.T) {
}
}
func TestSendMultiMediaPartialFailureSubsetRetryKeepsReservedGroupedID(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
files := r.deps.Files.(*fakeFiles)
first := tg.InputSingleMedia{
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}},
RandomID: 41001,
Message: "first",
}
second := tg.InputSingleMedia{
// 首次请求时 556 尚不存在,使第一条已提交后第二条解析失败。
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 556, AccessHash: 6}},
RandomID: 41002,
Message: "second",
}
peer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}
if _, err := r.onMessagesSendMultiMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMultiMediaRequest{
Peer: peer,
MultiMedia: []tg.InputSingleMedia{first, second},
}); err == nil || !tgerr.Is(err, "MEDIA_INVALID") {
t.Fatalf("partial album err=%v, want MEDIA_INVALID after first item commit", err)
}
files.docs[556] = domain.Document{ID: 556, AccessHash: 6, DCID: 2, MimeType: "image/jpeg"}
retry, err := r.onMessagesSendMultiMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMultiMediaRequest{
Peer: peer,
MultiMedia: []tg.InputSingleMedia{second},
})
if err != nil {
t.Fatalf("retry failed subset: %v", err)
}
retryMessage := newMessageFromUpdates(t, retry)
retryGroup, ok := retryMessage.GetGroupedID()
if !ok || retryGroup == 0 {
t.Fatalf("retry grouped_id = %d present=%v, want non-zero reservation", retryGroup, ok)
}
history, err := r.deps.Messages.GetHistory(ctx, owner.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("album history: %v", err)
}
groups := make(map[int64]int64, 2)
for _, message := range history.Messages {
if message.RandomID == first.RandomID || message.RandomID == second.RandomID {
groups[message.RandomID] = message.GroupedID
}
}
if len(groups) != 2 || groups[first.RandomID] != retryGroup || groups[second.RandomID] != retryGroup {
t.Fatalf("history album groups=%v, want both %d", groups, retryGroup)
}
changed := second
changed.Message = "changed durable intent"
if _, err := r.onMessagesSendMultiMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMultiMediaRequest{
Peer: peer,
MultiMedia: []tg.InputSingleMedia{changed},
}); err == nil || !tgerr.Is(err, "RANDOM_ID_DUPLICATE") {
t.Fatalf("changed reserved item err=%v, want RANDOM_ID_DUPLICATE", err)
}
}
func TestSendMultiMediaChannelPartialFailureSubsetRetryKeepsReservedGroupedID(t *testing.T) {
f := newRPCChannelFixture(t)
owner := f.user(51, "15550009401", "AlbumOwner")
member := f.user(52, "15550009402", "AlbumMember")
channel := f.createLegacyMegagroup(owner, "Album Group", member)
messageStore := memory.NewMessageStore()
f.router.deps.Messages = appmessages.NewService(messageStore, nil)
files := &fakeFiles{docs: map[int64]domain.Document{
555: {ID: 555, AccessHash: 5, DCID: 2, MimeType: "image/jpeg"},
}, photos: map[int64]domain.Photo{}}
f.router.deps.Files = files
first := tg.InputSingleMedia{
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}}, RandomID: 42001, Message: "first",
}
second := tg.InputSingleMedia{
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 556, AccessHash: 6}}, RandomID: 42002, Message: "second",
}
peer := inputPeerChannel(channel)
if _, err := f.router.onMessagesSendMultiMedia(f.userCtx(owner), &tg.MessagesSendMultiMediaRequest{
Peer: peer, MultiMedia: []tg.InputSingleMedia{first, second},
}); err == nil || !tgerr.Is(err, "MEDIA_INVALID") {
t.Fatalf("partial channel album err=%v, want MEDIA_INVALID", err)
}
files.docs[556] = domain.Document{ID: 556, AccessHash: 6, DCID: 2, MimeType: "image/jpeg"}
retry, err := f.router.onMessagesSendMultiMedia(f.userCtx(owner), &tg.MessagesSendMultiMediaRequest{
Peer: peer, MultiMedia: []tg.InputSingleMedia{second},
})
if err != nil {
t.Fatalf("retry channel subset: %v", err)
}
retryMessage := newMessageFromUpdates(t, retry)
retryGroup, ok := retryMessage.GetGroupedID()
if !ok || retryGroup == 0 {
t.Fatalf("channel retry grouped_id=%d present=%v, want non-zero", retryGroup, ok)
}
history, err := f.router.deps.Channels.GetHistory(f.ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("channel album history: %v", err)
}
groups := make(map[int64]int64, 2)
for _, message := range history.Messages {
if message.RandomID == first.RandomID || message.RandomID == second.RandomID {
groups[message.RandomID] = message.GroupedID
}
}
if len(groups) != 2 || groups[first.RandomID] != retryGroup || groups[second.RandomID] != retryGroup {
t.Fatalf("channel album groups=%v, want both %d", groups, retryGroup)
}
}
func TestTGMessageMediaDocumentMarksHistoricalStickerNopremium(t *testing.T) {
media := tgMessageMedia(&domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,

118
internal/rpc/send_replay.go Normal file
View file

@ -0,0 +1,118 @@
package rpc
import (
"context"
"crypto/sha256"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// These optional capabilities keep the broad RPC service interfaces stable for compatibility
// fakes while production app services expose the read-only receipt lookup.
type privateSendReplayService interface {
LookupPrivateSendReplay(ctx context.Context, userID int64, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error)
}
type channelSendReplayService interface {
LookupChannelSendReplay(ctx context.Context, userID int64, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error)
}
type outgoingReplayLookup struct {
private domain.SendPrivateTextResult
channel domain.SendChannelMessageResult
found bool
checked bool
}
// lookupOutgoingReplay is deliberately limited to authenticated sender + destination scope +
// immutable fingerprint. It does not resolve media/replies/send-as/source messages, consume rate
// budget, run a send permission gate or emit any realtime/durable side effect.
func (r *Router) lookupOutgoingReplay(ctx context.Context, userID int64, peer domain.Peer, randomID int64, fingerprint []byte) (outgoingReplayLookup, error) {
if randomID == 0 || len(fingerprint) != sha256.Size {
return outgoingReplayLookup{}, nil
}
switch peer.Type {
case domain.PeerTypeUser:
service, ok := r.deps.Messages.(privateSendReplayService)
if !ok {
return outgoingReplayLookup{}, nil
}
res, found, err := service.LookupPrivateSendReplay(ctx, userID, domain.PrivateSendReplayRequest{
SenderUserID: userID,
RecipientUserID: peer.ID,
RandomID: randomID,
IdempotencyFingerprint: fingerprint,
})
if err != nil {
return outgoingReplayLookup{checked: true}, messageSendErr(err)
}
return outgoingReplayLookup{private: res, found: found, checked: true}, nil
case domain.PeerTypeChannel:
return r.lookupChannelSendReplay(ctx, userID, peer.ID, domain.Peer{}, randomID, fingerprint)
default:
return outgoingReplayLookup{}, peerIDInvalidErr()
}
}
func (r *Router) lookupChannelSendReplay(ctx context.Context, userID, channelID int64, savedPeer domain.Peer, randomID int64, fingerprint []byte) (outgoingReplayLookup, error) {
if randomID == 0 || len(fingerprint) != sha256.Size {
return outgoingReplayLookup{}, nil
}
service, ok := r.deps.Channels.(channelSendReplayService)
if !ok {
return outgoingReplayLookup{}, nil
}
res, found, err := service.LookupChannelSendReplay(ctx, userID, domain.ChannelSendReplayRequest{
ChannelID: channelID,
SenderUserID: userID,
SavedPeer: savedPeer,
RandomID: randomID,
IdempotencyFingerprint: fingerprint,
})
if err != nil {
return outgoingReplayLookup{checked: true}, channelInvalidErr(err)
}
return outgoingReplayLookup{channel: res, found: found, checked: true}, nil
}
func (r *Router) outgoingReplayUpdates(ctx context.Context, userID int64, peer domain.Peer, randomID int64, replay outgoingReplayLookup) tg.UpdatesClass {
if peer.Type == domain.PeerTypeChannel {
return r.channelMessageUpdatesWithPeerCache(ctx, userID, replay.channel, randomID, newViewerPeerCache(r))
}
return tgPrivateSendResultUpdates(replay.private, randomID, true, nil, nil)
}
func combineSendUpdates(results []tg.UpdatesClass) *tg.Updates {
combined := make([]tg.UpdateClass, 0, len(results)*2)
usersByID := map[int64]tg.UserClass{}
chatsByID := map[int64]tg.ChatClass{}
date := 0
for _, result := range results {
upd, ok := result.(*tg.Updates)
if !ok || upd == nil {
continue
}
combined = append(combined, upd.Updates...)
for _, user := range upd.Users {
if id := userClassID(user); id != 0 {
usersByID[id] = user
}
}
for _, chat := range upd.Chats {
if id := chatClassID(chat); id != 0 {
chatsByID[id] = chat
}
}
if upd.Date > date {
date = upd.Date
}
}
return &tg.Updates{
Updates: combined,
Users: mapValuesUsers(usersByID),
Chats: mapValuesChats(chatsByID),
Date: date,
}
}

View file

@ -2372,7 +2372,7 @@ func (r *Router) onStoriesReadStories(ctx context.Context, req *tg.StoriesReadSt
if read.Advanced && r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
if _, _, err := r.deps.Updates.RecordReadStories(ctx, authKeyID, userID, read, sessionID); err != nil {
if _, _, err := r.deps.Updates.RecordReadStories(ctx, authKeyID, userID, read, rawAuthKeyIDForOrigin(ctx), sessionID); err != nil {
return nil, internalErr()
}
}
@ -2689,11 +2689,11 @@ func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendR
if res.Changed && r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
if _, _, err := r.deps.Updates.RecordSentStoryReaction(ctx, authKeyID, userID, res, sessionID); err != nil {
if _, _, err := r.deps.Updates.RecordSentStoryReaction(ctx, authKeyID, userID, res, rawAuthKeyIDForOrigin(ctx), sessionID); err != nil {
return nil, internalErr()
}
if ownerUserID, ok := ownerStoryReactionNotificationUserID(res, userID); ok && res.Reaction != nil {
event, _, err := r.deps.Updates.RecordNewStoryReaction(ctx, [8]byte{}, ownerUserID, res, 0)
event, _, err := r.deps.Updates.RecordNewStoryReaction(ctx, [8]byte{}, ownerUserID, res, [8]byte{}, 0)
if err != nil {
return nil, internalErr()
}
@ -3028,7 +3028,7 @@ func (r *Router) recordStoryChange(ctx context.Context, userID int64, story doma
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
if _, _, err := r.deps.Updates.RecordStory(ctx, authKeyID, userID, story, sessionID); err != nil {
if _, _, err := r.deps.Updates.RecordStory(ctx, authKeyID, userID, story, rawAuthKeyIDForOrigin(ctx), sessionID); err != nil {
return internalErr()
}
return nil

View file

@ -6,7 +6,7 @@ import (
"time"
)
const defaultTempKeyResolveCacheMaxEntries = 4096
const defaultTempKeyResolveCacheMaxEntries = 262144
type tempKeyResolveEntry struct {
perm [8]byte
@ -22,6 +22,7 @@ type tempKeyResolveCache struct {
mu sync.Mutex
max int
entries map[[8]byte]*list.Element
byPerm map[[8]byte]map[[8]byte]struct{}
order *list.List
}
@ -30,8 +31,12 @@ func newTempKeyResolveCache(maxEntries int) *tempKeyResolveCache {
maxEntries = defaultTempKeyResolveCacheMaxEntries
}
return &tempKeyResolveCache{
max: maxEntries,
entries: make(map[[8]byte]*list.Element, maxEntries),
max: maxEntries,
// maxEntries is an eviction ceiling, not an expected steady-state population. A
// capacity hint of 262k eagerly reserves a large hash table for every Router even when
// PFS/temp keys are never used; let the map grow lazily with actual bindings instead.
entries: make(map[[8]byte]*list.Element),
byPerm: make(map[[8]byte]map[[8]byte]struct{}),
order: list.New(),
}
}
@ -55,20 +60,25 @@ func (c *tempKeyResolveCache) Get(rawAuthKeyID, expectedPermAuthKeyID [8]byte, n
return item.entry.perm, true
}
func (c *tempKeyResolveCache) Store(rawAuthKeyID, permAuthKeyID [8]byte, expireAt, now time.Time) {
func (c *tempKeyResolveCache) Store(rawAuthKeyID, permAuthKeyID [8]byte, expireAt, _ time.Time) {
if c == nil || c.max <= 0 || rawAuthKeyID == ([8]byte{}) || permAuthKeyID == ([8]byte{}) {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if el := c.entries[rawAuthKeyID]; el != nil {
old := el.Value.(tempKeyResolveCacheItem)
if old.entry.perm != permAuthKeyID {
c.removeReverseLocked(old.raw, old.entry.perm)
c.addReverseLocked(rawAuthKeyID, permAuthKeyID)
}
el.Value = tempKeyResolveCacheItem{raw: rawAuthKeyID, entry: tempKeyResolveEntry{perm: permAuthKeyID, expireAt: expireAt}}
c.order.MoveToBack(el)
return
}
c.evictExpiredLocked(now)
el := c.order.PushBack(tempKeyResolveCacheItem{raw: rawAuthKeyID, entry: tempKeyResolveEntry{perm: permAuthKeyID, expireAt: expireAt}})
c.entries[rawAuthKeyID] = el
c.addReverseLocked(rawAuthKeyID, permAuthKeyID)
for len(c.entries) > c.max {
c.removeElementLocked(c.order.Front())
}
@ -91,35 +101,43 @@ func (c *tempKeyResolveCache) DeleteByPerm(permAuthKeyID [8]byte) [][8]byte {
}
c.mu.Lock()
defer c.mu.Unlock()
rawAuthKeyIDs := make([][8]byte, 0)
for el := c.order.Front(); el != nil; {
next := el.Next()
item := el.Value.(tempKeyResolveCacheItem)
if item.entry.perm == permAuthKeyID {
rawAuthKeyIDs = append(rawAuthKeyIDs, item.raw)
raws := c.byPerm[permAuthKeyID]
rawAuthKeyIDs := make([][8]byte, 0, len(raws))
for raw := range raws {
rawAuthKeyIDs = append(rawAuthKeyIDs, raw)
if el := c.entries[raw]; el != nil {
c.removeElementLocked(el)
}
el = next
}
return rawAuthKeyIDs
}
func (c *tempKeyResolveCache) evictExpiredLocked(now time.Time) {
for el := c.order.Front(); el != nil; {
next := el.Next()
item := el.Value.(tempKeyResolveCacheItem)
if !item.entry.expireAt.After(now) {
c.removeElementLocked(el)
}
el = next
}
}
func (c *tempKeyResolveCache) removeElementLocked(el *list.Element) {
if el == nil {
return
}
item := el.Value.(tempKeyResolveCacheItem)
delete(c.entries, item.raw)
c.removeReverseLocked(item.raw, item.entry.perm)
c.order.Remove(el)
}
func (c *tempKeyResolveCache) addReverseLocked(raw, perm [8]byte) {
raws := c.byPerm[perm]
if raws == nil {
raws = make(map[[8]byte]struct{})
c.byPerm[perm] = raws
}
raws[raw] = struct{}{}
}
func (c *tempKeyResolveCache) removeReverseLocked(raw, perm [8]byte) {
raws := c.byPerm[perm]
if raws == nil {
return
}
delete(raws, raw)
if len(raws) == 0 {
delete(c.byPerm, perm)
}
}

View file

@ -14,11 +14,11 @@ func (r *Router) registerUpdates(d *tg.ServerDispatcher) {
d.OnUpdatesGetDifference(r.onUpdatesGetDifference)
}
// onUpdatesGetState 处理 updates.getState:返回账号当前最新连续状态并推进该设备
// 的确认水位。协议语义是客户端宣告「从现在开始同步」,启动期离线数据由
// getDialogs 快照承载——返回设备旧确认水位会让 TDesktop不持久化 pts、每次
// 启动都调 getState在 getDialogs 快照之上重放历史差分,未读重复累计、
// dialog 预览被旧消息抢占
// onUpdatesGetState 处理 updates.getState。TDesktop 与 DrKLO 的启动路径把它当作
// 「从当前快照开始同步」的显式 baseline返回账号当前连续水位并推进该设备 observed。
// 对无法识别的客户端仍返回同一 current state但不把尚未被客户端带回的服务端快照
// 记成 observed这保留 durable difference tail避免把 TDesktop/DrKLO 的兼容例外
// 扩散成所有客户端都能跨过未实际确认事件的 retention 后门
func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)
@ -29,7 +29,16 @@ func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error
r.markSessionReceivesUpdates(ctx, userID)
return &tg.UpdatesState{Date: int(r.clock.Now().Unix()), Qts: r.deviceEncryptedQts(ctx)}, nil
}
st, err := r.deps.Updates.AcknowledgeCurrentState(ctx, id, userID)
var st domain.UpdateState
if getStateEstablishesObservedBaseline(ctx) {
st, err = r.deps.Updates.AcknowledgeCurrentState(ctx, id, userID)
} else {
st, err = r.deps.Updates.CurrentState(ctx, userID)
if err == nil {
r.log.Warn("updates.getState returned current snapshot without advancing observed baseline for unknown client",
r.contextLogFields(ctx)...)
}
}
if err != nil {
return nil, internalErr()
}
@ -41,6 +50,15 @@ func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error
return ptr(out), nil
}
func getStateEstablishesObservedBaseline(ctx context.Context) bool {
switch ClientTypeFrom(ctx) {
case ClientTypeTDesktop, ClientTypeAndroid:
return true
default:
return false
}
}
func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetDifferenceRequest) (tg.UpdatesDifferenceClass, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)
@ -76,7 +94,7 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
encMsgs, newQts := r.encryptedDifference(ctx, req.Qts)
// 密聊握手/已读状态事件(无 qts按未投递标记补回 OtherUpdates。
stateUpdates, statePeerUserIDs, stateEventIDs := r.encryptedStateUpdates(ctx, userID)
if len(st.Events) == 0 && len(st.ChannelNudges) == 0 && len(encMsgs) == 0 && len(stateUpdates) == 0 {
if !st.Partial && len(st.Events) == 0 && len(st.ChannelNudges) == 0 && len(encMsgs) == 0 && len(stateUpdates) == 0 {
r.registerBootstrapAfterBaseline(ctx, userID)
return &tg.UpdatesDifferenceEmpty{Date: st.State.Date, Seq: st.State.Seq}, nil
}

View file

@ -17,7 +17,7 @@ import (
"telesrv/internal/store/memory"
)
func TestBootstrapLoginMessagePublishesNewMessageAfterReady(t *testing.T) {
func TestSignUpBootstrapLoginMessagePublishesNewMessageAfterReady(t *testing.T) {
bootstrap := memory.NewBootstrapUpdateJobStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000}}
messages := &captureMessages{
@ -70,7 +70,44 @@ func TestBootstrapLoginMessagePublishesNewMessageAfterReady(t *testing.T) {
}
}
func TestUpdatesGetStatePublishesBootstrapAfterRPCResult(t *testing.T) {
func TestSignUpBootstrapPendingJobFollowsSameAuthKeyReconnect(t *testing.T) {
bootstrap := memory.NewBootstrapUpdateJobStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 0, Date: 1700000000}}
msg := domain.Message{
ID: 7, OwnerUserID: 1780243777,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100, Body: "Login code: 12345",
}
r := New(Config{}, Deps{
BootstrapUpdates: bootstrap,
Updates: updates,
Messages: &captureMessages{list: domain.MessageList{Messages: []domain.Message{msg}}},
}, zaptest.NewLogger(t), clock.System)
authKeyID := [8]byte{4, 5, 6}
oldSessionID := int64(1001)
newSessionID := int64(2002)
r.enqueueLoginMessageBootstrap(
WithSessionID(WithAuthKeyID(context.Background(), authKeyID), oldSessionID),
msg,
)
if ready, err := bootstrap.MarkReadyForSession(context.Background(), msg.OwnerUserID, [8]byte{9}, newSessionID); err != nil || ready != 0 {
t.Fatalf("different auth-key ready=%d err=%v, want 0/nil", ready, err)
}
ready, err := bootstrap.MarkReadyForSession(context.Background(), msg.OwnerUserID, authKeyID, newSessionID)
if err != nil || ready != 1 {
t.Fatalf("same auth-key reconnect ready=%d err=%v, want 1/nil", ready, err)
}
if claimed := r.publishReadyBootstrapUpdates(context.Background(), 1, time.Second, zaptest.NewLogger(t)); claimed != 1 {
t.Fatalf("published after same-auth reconnect = %d, want 1", claimed)
}
if len(updates.events) != 1 || updates.events[0].Message.ID != msg.ID {
t.Fatalf("events after reconnect = %+v", updates.events)
}
}
func TestUpdatesGetStatePublishesSignUpBootstrapAfterRPCResult(t *testing.T) {
bootstrap := memory.NewBootstrapUpdateJobStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 0, Date: 1700000000}}
msg := domain.Message{
@ -86,9 +123,12 @@ func TestUpdatesGetStatePublishesBootstrapAfterRPCResult(t *testing.T) {
authKeyID := [8]byte{9, 8, 7}
sessionID := int64(5723482677041206318)
ctx := postresponse.WithCallbacks(
WithUserID(
WithSessionID(WithAuthKeyID(context.Background(), authKeyID), sessionID),
msg.OwnerUserID,
WithClientInfo(
WithUserID(
WithSessionID(WithAuthKeyID(context.Background(), authKeyID), sessionID),
msg.OwnerUserID,
),
ClientInfo{Type: ClientTypeTDesktop},
),
)
r.enqueueLoginMessageBootstrap(ctx, msg)
@ -180,7 +220,7 @@ func TestLogOutClearsSessionAndUpdateState(t *testing.T) {
}
}
func TestSignInDifferentUserClearsAuthKeyUpdateState(t *testing.T) {
func TestSignInDifferentUserDoesNotClearFreshlyBoundUpdateState(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 6
auth := &captureAuthService{signInUser: domain.User{ID: 1000000002, FirstName: "Two"}}
@ -197,8 +237,8 @@ func TestSignInDifferentUserClearsAuthKeyUpdateState(t *testing.T) {
if err != nil {
t.Fatalf("auth.signIn: %v", err)
}
if updates.clearedAuthKeyID != authKeyID || !updates.cleared {
t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID)
if updates.cleared {
t.Fatalf("router cleared auth key %x after Bind; this would delete the new user's retained-floor baseline", updates.clearedAuthKeyID)
}
gotSession := sessions.snapshot()
if gotSession.userID != 1000000002 {
@ -213,7 +253,8 @@ func TestUpdatesGetStateMarksSessionReadyForPush(t *testing.T) {
Updates: &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000, Seq: 2}},
}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77))
ctx := WithClientInfo(WithSessionID(context.Background(), 77), ClientInfo{Type: ClientTypeTDesktop})
got, err := r.onUpdatesGetState(ctx)
if err != nil {
t.Fatalf("updates.getState: %v", err)
}
@ -243,7 +284,8 @@ func TestUpdatesGetStateReturnsAccountCurrentState(t *testing.T) {
Updates: updates,
}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77))
ctx := WithClientInfo(WithSessionID(context.Background(), 77), ClientInfo{Type: ClientTypeTDesktop})
got, err := r.onUpdatesGetState(ctx)
if err != nil {
t.Fatalf("updates.getState: %v", err)
}
@ -261,6 +303,43 @@ func TestUpdatesGetStateReturnsAccountCurrentState(t *testing.T) {
}
}
func TestUpdatesGetStateUnknownClientDoesNotAdvanceObservedBaseline(t *testing.T) {
sessions := &captureSessions{}
current := domain.UpdateState{Pts: 9, Date: 1700000009}
updates := &captureUpdates{
state: domain.UpdateState{Pts: 3, Date: 1700000003},
currentState: &current,
}
r := New(Config{}, Deps{Sessions: sessions, Updates: updates}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 78))
if err != nil {
t.Fatalf("updates.getState unknown client: %v", err)
}
if got.Pts != current.Pts {
t.Fatalf("state pts = %d, want current %d", got.Pts, current.Pts)
}
if updates.acknowledged {
t.Fatal("unknown client advanced the observed getState baseline")
}
if !sessions.snapshot().receives {
t.Fatal("unknown client was not enabled for subsequent updates")
}
}
func TestUpdatesGetStateDrKLOEstablishesObservedBaseline(t *testing.T) {
updates := &captureUpdates{currentState: &domain.UpdateState{Pts: 6, Date: 1700000006}}
r := New(Config{}, Deps{Sessions: &captureSessions{}, Updates: updates}, zaptest.NewLogger(t), clock.System)
ctx := WithClientInfo(context.Background(), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"})
if _, err := r.onUpdatesGetState(ctx); err != nil {
t.Fatalf("updates.getState DrKLO: %v", err)
}
if !updates.acknowledged {
t.Fatal("DrKLO getState did not establish its explicit current-snapshot baseline")
}
}
func TestUpdatesDifferenceIncludesLoginMessageAndOfficialUser(t *testing.T) {
msg := domain.Message{
ID: 88,
@ -575,6 +654,28 @@ func TestUpdatesGetDifferenceChannelNudgeIncludesFullChat(t *testing.T) {
}
}
func TestUpdatesGetDifferenceEmptyRetentionCheckpointStaysDifferenceSlice(t *testing.T) {
updates := &captureUpdates{difference: &domain.UpdateDifference{
State: domain.UpdateState{Pts: 42, Date: 1700000442},
Partial: true,
}}
r := New(Config{}, Deps{Updates: updates}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000443, 0)})
authKeyID := [8]byte{42}
ctx := WithUserID(WithAuthKeyID(context.Background(), authKeyID), 1000000042)
diff, err := r.onUpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{Pts: 1, Date: 1700000400})
if err != nil {
t.Fatalf("updates.getDifference: %v", err)
}
slice, ok := diff.(*tg.UpdatesDifferenceSlice)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifferenceSlice (never Empty/TooLong)", diff)
}
if slice.IntermediateState.Pts != 42 || slice.IntermediateState.Date != 1700000442 || len(slice.NewMessages) != 0 || len(slice.OtherUpdates) != 0 {
t.Fatalf("checkpoint slice = %+v, want empty payload at pts/date 42/1700000442", slice)
}
}
func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
got, ok := tgUpdatesDifference(0, domain.UpdateDifference{