perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
internal/rpc/album_group.go
Normal file
39
internal/rpc/album_group.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.importBotAuthorization:bot 程序凭 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
|
||||
|
|
|
|||
248
internal/rpc/auth_code_rate_limit_test.go
Normal file
248
internal/rpc/auth_code_rate_limit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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_events,worker 只做在线投递。originAuthKeyID 是业务视角 auth key
|
||||
// channel_update_events,worker 只做在线投递。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 只保留
|
||||
// 最大 pts;barrier 是该次 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(新消息/编辑/删除/pin);reaction/poll(viewer-only 零 pts)、participant/TTL/
|
||||
// channel state(无 channel pts)、typing(transient)不走此路径(设计 §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)。走 pushUserUpdates(best-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
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ func runIdleBackoffLoop(ctx context.Context, interval, maxIdleInterval time.Dura
|
|||
case <-timer.C:
|
||||
}
|
||||
if dispatch(ctx) {
|
||||
timer.Reset(backoff.ActiveDelay())
|
||||
// 有积压时立即继续 drain;interval 只用于空闲轮询。旧逻辑每个非空
|
||||
// batch 也固定等待 base,形成 batch/base 的人工吞吐上限。
|
||||
_ = backoff.ActiveDelay()
|
||||
timer.Reset(0)
|
||||
continue
|
||||
}
|
||||
timer.Reset(backoff.IdleDelay())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
84
internal/rpc/message_idempotency.go
Normal file
84
internal/rpc/message_idempotency.go
Normal 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)
|
||||
}
|
||||
315
internal/rpc/message_idempotency_test.go
Normal file
315
internal/rpc/message_idempotency_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
454
internal/rpc/message_replay_preflight_test.go
Normal file
454
internal/rpc/message_replay_preflight_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 用各自独立 cache(RPC vs worker goroutine 防竞态)。多条转发汇成
|
||||
// 一个 fan-out job(channelMessagesUpdatesWithPeerCache 内含多条 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_markup(bot 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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→maxIdleInterval),claim 到事件即复位到 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),
|
||||
|
|
|
|||
|
|
@ -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 0(context 中断不算投递失败)", 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
137
internal/rpc/request_preflight.go
Normal file
137
internal/rpc/request_preflight.go
Normal 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
|
||||
}
|
||||
134
internal/rpc/request_preflight_test.go
Normal file
134
internal/rpc/request_preflight_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 RPC(getChannelDifference /
|
||||
// getPeerDialogs)的每用户频率(设计 Phase 2 / §10.3):nudge 被消费后客户端会触发这两类
|
||||
// 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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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(上传则落库,引用则加载)。
|
||||
|
|
|
|||
|
|
@ -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
118
internal/rpc/send_replay.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ¤t,
|
||||
}
|
||||
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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue