merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -1042,6 +1042,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
return nil, internalErr()
}
r.accountSettings.Store(userID, saved)
r.invalidateRPCProjectionForUser(userID)
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
}
return &settings, nil
@ -1467,6 +1468,15 @@ func tgGlobalPrivacySettings(gp domain.GlobalPrivacy) *tg.GlobalPrivacySettings
if gp.NoncontactPeersPaidStars > 0 {
out.SetNoncontactPeersPaidStars(gp.NoncontactPeersPaidStars)
}
if !gp.DisallowedGifts.Zero() {
out.SetDisallowedGifts(tg.DisallowedGiftsSettings{
DisallowUnlimitedStargifts: gp.DisallowedGifts.UnlimitedStargifts,
DisallowLimitedStargifts: gp.DisallowedGifts.LimitedStargifts,
DisallowUniqueStargifts: gp.DisallowedGifts.UniqueStargifts,
DisallowPremiumGifts: gp.DisallowedGifts.PremiumGifts,
DisallowStargiftsFromChannels: gp.DisallowedGifts.StargiftsFromChannel,
})
}
return out
}
@ -1482,6 +1492,15 @@ func domainGlobalPrivacy(settings tg.GlobalPrivacySettings) domain.GlobalPrivacy
if v, ok := settings.GetNoncontactPeersPaidStars(); ok && v > 0 {
gp.NoncontactPeersPaidStars = v
}
if gifts, ok := settings.GetDisallowedGifts(); ok {
gp.DisallowedGifts = domain.DisallowedGifts{
UnlimitedStargifts: gifts.DisallowUnlimitedStargifts,
LimitedStargifts: gifts.DisallowLimitedStargifts,
UniqueStargifts: gifts.DisallowUniqueStargifts,
PremiumGifts: gifts.DisallowPremiumGifts,
StargiftsFromChannel: gifts.DisallowStargiftsFromChannels,
}
}
return gp
}

View file

@ -59,15 +59,11 @@ func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDele
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
}
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
r.invalidateRPCProjectionForUser(userID)
dispatchNotifications := func() {
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r.runAccountLifecycleOnce(dispatchCtx, 500)
}
if !postresponse.Register(ctx, dispatchNotifications) {
go dispatchNotifications()
}
r.invalidateDeletedUserProjectionFacts(userID)
// A tombstone changes this target for every viewer. Flushing once is bounded
// and avoids four full-cache predicate scans; the PostgreSQL user_deleted
// event performs the same coarse invalidation on other instances.
r.flushRPCProjectionCache()
return true, nil
}
@ -130,6 +126,26 @@ func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID
}
}
// NotifyModerationAccountDeletion completes the runtime half of the durable
// moderation tombstone. Moderation runs off-request, so every revoked auth key
// can be disconnected immediately; reconnects then observe the missing durable
// authorization and the auth service's tombstone guard.
func (r *Router) NotifyModerationAccountDeletion(ctx context.Context, result domain.AccountDeletionResult) {
if r == nil || !result.Changed || result.User.ID == 0 {
return
}
r.finishDeletedAccountAuthorizations(ctx, result.User.ID, result.RevokedAuthorizations)
r.invalidateDeletedUserProjectionFacts(result.User.ID)
r.flushRPCProjectionCache()
}
func (r *Router) invalidateDeletedUserProjectionFacts(userID int64) {
if r == nil || userID == 0 || r.deps.UserProjectionFacts == nil {
return
}
r.deps.UserProjectionFacts.InvalidateAccountFreezeFact(userID)
}
func accountDeletionErr(err error) error {
switch {
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):

View file

@ -8,7 +8,6 @@ import (
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
@ -33,7 +32,8 @@ func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T)
},
}
sessions := &deletionCaptureSessions{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
facts := &recordingUserProjectionFactInvalidator{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions, UserProjectionFacts: facts}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
if err != nil || !ok {
@ -45,10 +45,19 @@ func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T)
if !sessions.wasClosed(other) {
t.Fatal("other auth key was not revoked immediately")
}
if accountSvc.sweepCalls != 0 {
t.Fatalf("account lifecycle sweeps before rpc_result delivery = %d, want 0", accountSvc.sweepCalls)
}
if len(facts.freezes) != 1 || facts.freezes[0] != 42 || len(facts.phones) != 1 || facts.phones[0] != 42 {
t.Fatalf("deleted user fact invalidations freezes=%v phones=%v, want [42]/[42]", facts.freezes, facts.phones)
}
postresponse.Run(ctx)
if !sessions.wasClosed(current) {
t.Fatal("current auth key not closed after rpc_result delivery")
}
if accountSvc.sweepCalls != 0 {
t.Fatalf("account lifecycle sweeps after rpc_result delivery = %d, want 0", accountSvc.sweepCalls)
}
}
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
@ -73,18 +82,6 @@ func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
}
}
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
sessions := &offlineDeletionSessions{}
svc := &deletionWorkerService{}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
})
if len(svc.completed) != 1 || svc.completed[0] != 9 {
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
}
}
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
revoked := [8]byte{3}
svc := &rpcDeletionAccountService{
@ -104,12 +101,31 @@ func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
}
}
func TestModerationAccountDeletionClosesRevokedSessions(t *testing.T) {
revoked := [8]byte{4}
sessions := &deletionCaptureSessions{}
facts := &recordingUserProjectionFactInvalidator{}
r := New(Config{}, Deps{Sessions: sessions, UserProjectionFacts: facts}, zaptest.NewLogger(t), clock.System)
r.NotifyModerationAccountDeletion(context.Background(), domain.AccountDeletionResult{
Changed: true,
User: domain.User{ID: 42, Deleted: true},
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
})
if !sessions.wasClosed(revoked) {
t.Fatal("moderation-deleted authorization session was not closed")
}
if len(facts.freezes) != 1 || facts.freezes[0] != 42 || len(facts.phones) != 1 || facts.phones[0] != 42 {
t.Fatalf("moderation-deleted user fact invalidations freezes=%v phones=%v, want [42]/[42]", facts.freezes, facts.phones)
}
}
type rpcDeletionAccountService struct {
*appaccount.Service
outcome domain.AccountDeleteOutcome
err error
sweepResults []domain.AccountDeletionResult
sweepErr error
sweepCalls int
}
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
@ -133,6 +149,7 @@ func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64,
}
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
s.sweepCalls++
return s.sweepResults, s.sweepErr
}
@ -141,27 +158,6 @@ type deletionCaptureSessions struct {
closed [][8]byte
}
type offlineDeletionSessions struct{ captureSessions }
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
return 0, nil
}
type deletionWorkerService struct{ completed []int64 }
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
return nil, nil
}
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
s.completed = append(s.completed, id)
return nil
}
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
s.closed = append(s.closed, id)
return 1

View file

@ -10,11 +10,6 @@ import (
"telesrv/internal/domain"
)
type accountFreezeNotificationService interface {
ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error)
CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error
}
// RunAccountFreezeNotifications drains the crash-safe, coalesced non-pts
// updateUser queue. One attempt is enough for online delivery; offline clients
// recover the current state from viewer-scoped user hydration.
@ -39,8 +34,8 @@ func (r *Router) RunAccountFreezeNotifications(ctx context.Context, interval tim
}
func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int) {
svc, ok := r.deps.AccountFreeze.(accountFreezeNotificationService)
if !ok || r.deps.Users == nil {
svc := r.deps.AccountFreezeNotifications
if svc == nil || r.deps.Users == nil {
return
}
for {
@ -61,7 +56,7 @@ func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int)
}
}
func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc accountFreezeNotificationService, notification domain.AccountFreezeNotification) {
func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc AccountFreezeNotificationService, notification domain.AccountFreezeNotification) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: notification.FrozenUserID}
if contacts, ok := r.deps.Contacts.(interface{ InvalidateViewers(...int64) }); ok {
contacts.InvalidateViewers(notification.TargetUserID)

View file

@ -26,9 +26,9 @@ func TestAccountFreezeNotificationPushesCurrentViewerProjection(t *testing.T) {
RestrictionReasons: domain.AccountFrozenRestrictionReasons(),
}}
r := New(Config{}, Deps{
AccountFreeze: freezeSvc,
Users: users,
Sessions: sessions,
AccountFreezeNotifications: freezeSvc,
Users: users,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, domain.AccountFreezeNotification{
@ -67,9 +67,9 @@ func TestAccountFreezeNotificationLoadsCurrentStateAndRetriesLoadFailure(t *test
freezeSvc := &freezeWorkerService{}
users := &freezeWorkerUsers{err: errors.New("projection unavailable")}
r := New(Config{}, Deps{
AccountFreeze: freezeSvc,
Users: users,
Sessions: sessions,
AccountFreezeNotifications: freezeSvc,
Users: users,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
notification := domain.AccountFreezeNotification{
ID: 8, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 5, Frozen: true,

View file

@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
@ -12,16 +11,11 @@ import (
type accountLifecycleWorkerService interface {
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
}
// RunAccountLifecycle executes all due account deletion sources through one
// tombstone path and drains the durable non-pts updateUser queue. The queue is
// a crash-safe, bounded online nudge: offline users are completed after the
// first attempt because getDialogs/getHistory hydration independently returns
// the authoritative tombstone. This avoids an immortal retry queue for a
// non-pts update that cannot participate in getDifference.
// tombstone path. Deleted-user projections converge from authoritative reads;
// updateUser is non-PTS and therefore is not queued as a correctness signal.
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
if interval <= 0 {
interval = time.Minute
@ -51,49 +45,24 @@ func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
cancel()
changed := false
for _, result := range results {
if !result.Changed {
continue
}
r.invalidateRPCProjectionForUser(result.User.ID)
changed = true
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
}
if changed {
// One flush covers the entire due batch. Per-user predicate invalidation
// would scan the same large projection maps four times for every account.
r.flushRPCProjectionCache()
}
if err != nil {
// SweepDueAccountDeletions may return already-committed results before a
// later candidate fails. Always finish those sessions/caches and drain
// their durable notifications; the failed and remaining candidates are
// retried from their authoritative due rows on the next tick.
// later candidate fails. Always finish those sessions/caches; the failed
// and remaining candidates are retried from their authoritative due rows
// on the next tick.
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
}
for {
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
claimCancel()
if err != nil {
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
return
}
for _, notification := range notifications {
r.dispatchAccountDeletionNotification(ctx, svc, notification)
}
if len(notifications) < batch {
return
}
}
}
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
now := r.clock.Now().UTC()
updates := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
Users: []tg.UserClass{tgUser(domain.User{
ID: notification.DeletedUserID,
Deleted: true,
})},
Date: int(now.Unix()),
}
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
}
}

View file

@ -3,21 +3,11 @@ package rpc
import (
"context"
"go.uber.org/zap"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
type phoneChangeEventConfirmer interface {
ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error
}
type phoneChangeReliableDispatchReporter interface {
PhoneChangeUsesReliableDispatch() bool
}
func (r *Router) onAccountSendChangePhoneCode(ctx context.Context, req *tg.AccountSendChangePhoneCodeRequest) (tg.AuthSentCodeClass, error) {
userID, found, err := r.currentUserID(ctx)
if err != nil {
@ -73,22 +63,11 @@ func (r *Router) onAccountChangePhone(ctx context.Context, req *tg.AccountChange
return nil, internalErr()
}
r.invalidateRPCProjectionForUser(result.User.ID)
if result.Event.Pts > 0 {
if confirmer, ok := r.deps.Updates.(phoneChangeEventConfirmer); ok {
if err := confirmer.ConfirmEvent(ctx, authKeyID, userID, result.Event); err != nil {
// user/event/outbox 已原子提交,不能把已成功改号伪装成失败;当前
// session 仍会收到 pts 簿记,设备水位存储可由后续 getDifference 自愈。
r.log.Warn("confirm phone change event", zap.Int64("user_id", userID), zap.Int("pts", result.Event.Pts), zap.Error(err))
}
}
reliable := false
if reporter, ok := r.deps.Account.(phoneChangeReliableDispatchReporter); ok {
reliable = reporter.PhoneChangeUsesReliableDispatch()
}
if !reliable {
r.pushUserUpdates(ctx, userID, tgUpdateForOutboxEvent(result.Event))
}
r.bookkeepAuxPtsForCurrentSession(ctx, result.Event)
if result.Changed {
// account.changePhone returns the authoritative self User to the current
// session. Other online sessions receive a non-PTS updateUser; offline
// sessions converge on their next full-user/startup read.
r.pushPremiumStatusUpdate(ctx, result.User)
}
return r.tgSelfUserWithUsernames(ctx, result.User), nil
}

View file

@ -15,7 +15,7 @@ import (
"telesrv/internal/store/memory"
)
func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *testing.T) {
func TestAccountChangePhoneRPCReturnsSelfAndPushesNonPTSUpdate(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
auths := memory.NewAuthorizationStore()
@ -39,7 +39,7 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
{Username: "Alice", Editable: true, Active: true, SortOrder: 0},
{Username: "aliceCollect0728b", Active: true, SortOrder: 1, CollectibleID: 2},
}
sessions := &captureSessions{}
sessions := &captureSessions{onlineUserIDs: []int64{user.ID}}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions, Usernames: registry}, zaptest.NewLogger(t), clock.System)
reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 77)
@ -70,22 +70,23 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
assertVectorOnlyUsernames(t, "account.changePhone", self, []string{"Alice", "aliceCollect0728b"})
otherPush, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(otherPush.Updates) != 2 {
if !ok || len(otherPush.Updates) != 1 {
t.Fatalf("other-session push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
phoneUpdate, ok := otherPush.Updates[0].(*tg.UpdateUserPhone)
if !ok || phoneUpdate.UserID != user.ID || phoneUpdate.Phone != "15550013002" {
t.Fatalf("phone update = %T %+v", otherPush.Updates[0], otherPush.Updates[0])
userUpdate, ok := otherPush.Updates[0].(*tg.UpdateUser)
if !ok || userUpdate.UserID != user.ID {
t.Fatalf("user update = %T %+v", otherPush.Updates[0], otherPush.Updates[0])
}
if _, ok := otherPush.Updates[1].(*tg.UpdateDeleteMessages); !ok {
t.Fatalf("pts bookkeeping = %T", otherPush.Updates[1])
if len(otherPush.Users) != 1 {
t.Fatalf("push users = %+v", otherPush.Users)
}
currentPush, ok := sessions.snapshot().message.(*tg.Updates)
if !ok || len(currentPush.Updates) != 1 {
t.Fatalf("current-session bookkeeping = %T %+v", sessions.snapshot().message, sessions.snapshot().message)
pushedSelf, ok := otherPush.Users[0].(*tg.User)
if !ok || pushedSelf.Phone != "15550013002" {
t.Fatalf("pushed self = %T %+v", otherPush.Users[0], otherPush.Users[0])
}
if _, ok := currentPush.Updates[0].(*tg.UpdateDeleteMessages); !ok {
t.Fatalf("current bookkeeping update = %T", currentPush.Updates[0])
snapshot := sessions.snapshot()
if sessions.rawAuthKeyID != authKeyID || snapshot.sessionID != 77 {
t.Fatalf("push exclusion = %x/%d", sessions.rawAuthKeyID, snapshot.sessionID)
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), events)
@ -93,13 +94,8 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
if err != nil {
t.Fatalf("get difference: %v", err)
}
tgDiff, ok := tgUpdatesDifference(user.ID, diff).(*tg.UpdatesDifference)
if !ok || len(tgDiff.OtherUpdates) != 1 {
t.Fatalf("difference = %T %+v", tgUpdatesDifference(user.ID, diff), tgUpdatesDifference(user.ID, diff))
}
replayed, ok := tgDiff.OtherUpdates[0].(*tg.UpdateUserPhone)
if !ok || replayed.UserID != user.ID || replayed.Phone != "15550013002" {
t.Fatalf("replayed update = %T %+v", tgDiff.OtherUpdates[0], tgDiff.OtherUpdates[0])
if diff.State.Pts != 0 || len(diff.Events) != 0 {
t.Fatalf("difference unexpectedly changed = %+v", diff)
}
}

View file

@ -56,6 +56,10 @@ func TestAccountSettingsRoundTrip(t *testing.T) {
NewNoncontactPeersRequirePremium: true,
}
in.SetNoncontactPeersPaidStars(50)
in.SetDisallowedGifts(tg.DisallowedGiftsSettings{
DisallowLimitedStargifts: true,
DisallowPremiumGifts: true,
})
saved, err := r.onAccountSetGlobalPrivacySettings(ctx, in)
if err != nil {
t.Fatalf("set global privacy: %v", err)
@ -135,4 +139,10 @@ func assertGlobalPrivacy(t *testing.T, got *tg.GlobalPrivacySettings, want tg.Gl
if gotStars != wantStars {
t.Fatalf("noncontact paid stars = %d, want %d", gotStars, wantStars)
}
wantGifts, wantGiftsOK := want.GetDisallowedGifts()
gotGifts, gotGiftsOK := got.GetDisallowedGifts()
if gotGiftsOK != wantGiftsOK || gotGifts != wantGifts {
t.Fatalf("disallowed gifts = %+v ok=%v, want %+v ok=%v",
gotGifts, gotGiftsOK, wantGifts, wantGiftsOK)
}
}

View file

@ -39,6 +39,9 @@ func (r *Router) NotifyAccountFreezeChanged(_ context.Context, freeze domain.Acc
if r == nil || freeze.UserID == 0 {
return nil
}
if r.deps.UserProjectionFacts != nil {
r.deps.UserProjectionFacts.InvalidateAccountFreezeFact(freeze.UserID)
}
r.invalidateRPCProjectionForUser(freeze.UserID)
if r.accountFreezeWake != nil {
select {

View file

@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
@ -216,29 +217,43 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
id, _ = AuthKeyIDFrom(ctx)
}
sessionID, _ := SessionIDFrom(ctx)
if err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
boundState, err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: id,
PermAuthKeyID: req.PermAuthKeyID,
Nonce: req.Nonce,
ExpiresAt: req.ExpiresAt,
EncryptedMessage: append([]byte(nil), req.EncryptedMessage...),
}); err != nil {
})
if err != nil {
return false, bindTempAuthKeyErr(err)
}
permID := authKeyIDFromInt64(req.PermAuthKeyID)
// temp key (re)bind 后立即作废其 temp→perm 解析缓存,确保下一帧按新绑定重新解析,
// 不被 TTL 内的旧 perm 缓存命中(防跨账号串号)。
// The committed bind transaction is authoritative for this immutable
// temp→permanent identity. Replace any prior local entry, then publish the
// exact positive mapping so Layer publication and the first business RPC do
// not re-read the same row. A competing different-permanent bind has already
// failed in the store before reaching this point.
if id != ([8]byte{}) {
r.tempKeyResolveCache.Delete(id)
r.cacheResolvedAuthKey(id, permID)
}
// Save atomically merged raw/permanent Layer observations. Both identities
// must now re-read that durable permanent primary; pre-bind process caches
// are not ordering evidence and cannot overwrite the transaction's winner.
// Save atomically merged raw/permanent Layer observations and returned the
// exact committed tuple. Project that generation directly; a post-commit
// read could observe a later selector and wrongly attribute it to this bind.
r.invalidateAuthUserCache(id)
r.invalidateAuthUserCache(permID)
unlockLayerCommit := r.lockAuthLayerCommit(id, permID)
defer unlockLayerCommit()
r.invalidateBoundAuthKeyLayerResolution(id, permID)
layer, blocked, err := r.cacheBoundAuthKeyLayerResolution(id, permID, boundState)
if err != nil {
if r.log != nil {
r.log.Error("project committed temp auth key bind Layer failed",
zap.String("raw_auth_key_id", fmt.Sprintf("%x", id[:])),
zap.String("perm_auth_key_id", fmt.Sprintf("%x", permID[:])),
zap.Error(err))
}
return false, internalErr()
}
if r.deps.Sessions != nil {
if all, ok := r.deps.Sessions.(RawAuthKeySessionBinder); ok {
all.BindAuthKeyForRawAuthKey(id, permID)
@ -246,72 +261,86 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
r.deps.Sessions.BindAuthKeyForSession(id, sessionID, permID)
}
}
layer, _, err := r.resolveAuthKeyLayerDefault(ctx, permID)
if err != nil {
if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
clearer.ClearInheritedLayerForRawAuthKey(id)
}
if r.log != nil {
r.log.Warn("reload merged permanent layer after temp auth key bind failed",
zap.String("raw_auth_key_id", fmt.Sprintf("%x", id[:])),
zap.String("perm_auth_key_id", fmt.Sprintf("%x", permID[:])),
zap.Error(err))
}
return false, internalErr()
}
r.cacheBoundAuthKeyLayerResolution(id, permID)
if isSupportedLayer(layer) {
if refresher, ok := r.deps.Sessions.(AuthKeyLayerRefresher); ok {
refresher.RefreshInheritedLayerForRawAuthKey(id, layer)
} else if binder, ok := r.deps.Sessions.(AuthKeyLayerBinder); ok {
binder.SeedInheritedLayerForRawAuthKey(id, layer)
}
} else if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
clearer.ClearInheritedLayerForRawAuthKey(id)
} else if blocked || layer == 0 {
if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
clearer.ClearInheritedLayerForRawAuthKey(id)
}
}
return true, nil
}
func (r *Router) invalidateBoundAuthKeyLayerResolution(authKeyIDs ...[8]byte) {
func (r *Router) cacheBoundAuthKeyLayerResolution(
rawAuthKeyID, permAuthKeyID [8]byte,
result domain.TempAuthKeyBindingResult,
) (layer int, blocked bool, err error) {
if result.Layer < 0 || result.LayerObservationID < 0 ||
(result.LayerObservationID > 0 && result.Layer == 0) {
return 0, false, fmt.Errorf(
"invalid bound auth-key Layer result layer=%d observation=%d",
result.Layer, result.LayerObservationID,
)
}
outcome := clientSessionInfo{layerObservationID: result.LayerObservationID}
if isSupportedLayer(result.Layer) {
outcome.layer = result.Layer
} else if result.Layer != 0 {
outcome.layerBlocked = true
outcome.layerBlockedByAuthKey = true
}
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
for _, authKeyID := range authKeyIDs {
if info, ok := r.authInfo[authKeyID]; ok {
info.layer = 0
info.layerObservationID = 0
info.layerAdmissionSeq = 0
info.authKeyInfoChecked = false
info.authorizationChecked = false
info.layerBlocked = false
info.layerBlockedByAuthKey = false
r.authInfo[authKeyID] = info
for _, authKeyID := range [][8]byte{rawAuthKeyID, permAuthKeyID} {
current := r.authInfo[authKeyID]
switch {
case current.layerObservationID > outcome.layerObservationID:
outcome.layer = current.layer
outcome.layerObservationID = current.layerObservationID
outcome.layerBlocked = current.layerBlocked
outcome.layerBlockedByAuthKey = current.layerBlockedByAuthKey
case current.layerObservationID == outcome.layerObservationID && outcome.layerObservationID > 0:
currentBlocked := current.layerBlocked || current.layerBlockedByAuthKey
outcomeBlocked := outcome.layerBlocked || outcome.layerBlockedByAuthKey
if current.layer != 0 && outcome.layer != 0 && current.layer != outcome.layer {
return 0, false, fmt.Errorf(
"conflicting cached bound auth-key Layer observation %d: %d != %d",
outcome.layerObservationID, current.layer, outcome.layer,
)
}
if currentBlocked != outcomeBlocked &&
(current.layer != 0 || outcome.layer != 0 || currentBlocked || outcomeBlocked) {
return 0, false, fmt.Errorf(
"conflicting cached bound auth-key blocked observation %d",
outcome.layerObservationID,
)
}
if outcome.layer == 0 {
outcome.layer = current.layer
}
}
}
}
func (r *Router) cacheBoundAuthKeyLayerResolution(rawAuthKeyID, permAuthKeyID [8]byte) {
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
if r.authInfo == nil {
r.authInfo = make(map[[8]byte]clientSessionInfo)
}
if _, exists := r.authInfo[rawAuthKeyID]; !exists {
evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries)
for _, authKeyID := range [][8]byte{rawAuthKeyID, permAuthKeyID} {
if _, exists := r.authInfo[authKeyID]; !exists {
evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries)
}
info := r.authInfo[authKeyID]
info.layer = outcome.layer
info.layerObservationID = outcome.layerObservationID
info.layerAdmissionSeq = 0
info.layerBlocked = outcome.layerBlocked
info.layerBlockedByAuthKey = outcome.layerBlockedByAuthKey
r.authInfo[authKeyID] = info
}
canonical := r.authInfo[permAuthKeyID]
info := r.authInfo[rawAuthKeyID]
// The bind transaction made the permanent row authoritative for both
// identities. Copy its complete resolution tuple: a Layer without the same
// observation token (or a stale blocked bit) would let later cache merging
// manufacture an ordering state that never existed durably.
info.layer = canonical.layer
info.layerObservationID = canonical.layerObservationID
info.layerAdmissionSeq = canonical.layerAdmissionSeq
info.layerBlocked = canonical.layerBlocked
info.layerBlockedByAuthKey = canonical.layerBlockedByAuthKey
info.authKeyInfoChecked = canonical.authKeyInfoChecked
info.authorizationChecked = canonical.authorizationChecked
r.authInfo[rawAuthKeyID] = info
return outcome.layer, outcome.layerBlocked || outcome.layerBlockedByAuthKey, nil
}
// onAuthExportLoginToken 给 QR 登录请求方返回短期 token扫码端接受后同一目标
@ -473,9 +502,25 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
}
// The public MTProto error intentionally stays opaque, but operators need
// the wrapped store/provider cause to repair an update-related failure.
// Hash the normalized phone so neither the number nor the OTP reaches logs.
phoneDigest := sha256.Sum256([]byte(domain.NormalizePhone(req.PhoneNumber)))
fields := append(r.contextLogFields(ctx),
zap.Int("api_id", req.APIID),
zap.String("phone_digest", hex.EncodeToString(phoneDigest[:8])),
zap.Error(err),
)
r.log.Error("auth.sendCode failed", fields...)
return nil, internalErr()
}
return r.tgSentCodeForHash(ctx, hash)
sent, err := r.tgSentCodeForHash(ctx, hash)
if err != nil {
fields := append(r.contextLogFields(ctx), zap.Error(err))
r.log.Error("auth.sendCode delivery lookup failed", fields...)
return nil, err
}
return sent, nil
}
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
@ -828,7 +873,7 @@ func (r *Router) completePendingPasswordSignIn(ctx context.Context, authKeyID [8
if r.deps.Auth == nil {
return nil
}
if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID); err != nil {
if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID, userID); err != nil {
return err
}
r.invalidateAuthUserCache(authKeyID)

View file

@ -101,6 +101,26 @@ func TestAuthSendCodeRateLimitUsesOpaquePhoneAndRawAuthKeyKeys(t *testing.T) {
}
}
func TestAuthCodeRateLimitSharesBudgetAcrossNationalTrunkVariants(t *testing.T) {
limiter := &captureRateLimiter{}
r := New(Config{
AuthCodePhoneRateLimit: 5,
AuthCodeRateWindow: time.Minute,
}, Deps{Limiter: limiter}, zaptest.NewLogger(t), clock.System)
for _, phone := range []string{"+98 0998 167 9461", "989981679461"} {
if err := r.checkAuthCodeRateLimit(context.Background(), phone); err != nil {
t.Fatalf("checkAuthCodeRateLimit(%q): %v", phone, err)
}
}
if len(limiter.calls) != 2 {
t.Fatalf("limiter calls = %d, want 2", len(limiter.calls))
}
if limiter.calls[0].key != limiter.calls[1].key {
t.Fatalf("equivalent phone variants used different limiter keys: %q != %q", limiter.calls[0].key, limiter.calls[1].key)
}
}
func TestAuthSendCodePhoneRateLimitPrecedesBusinessLookupAndWrite(t *testing.T) {
limiter := &captureRateLimiter{block: true, retryAfter: 17}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}

View file

@ -96,8 +96,9 @@ func TestAuthRecoverPasswordCompletesPendingSignIn(t *testing.T) {
if _, err := router.onAuthRecoverPassword(ctx, &tg.AuthRecoverPasswordRequest{Code: sender.code}); err != nil {
t.Fatalf("auth.recoverPassword: %v", err)
}
if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID {
t.Fatalf("CompletePasswordSignIn count=%d key=%x, want one call for %x", auth.completePasswordCount, auth.completedPasswordKey, authKeyID)
if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID || auth.completedPasswordUser != userID {
t.Fatalf("CompletePasswordSignIn count=%d key=%x user=%d, want one call for %x/%d",
auth.completePasswordCount, auth.completedPasswordKey, auth.completedPasswordUser, authKeyID, userID)
}
if snap := sessions.snapshot(); snap.userID != userID || !snap.userResolved {
t.Fatalf("session user = %d resolved=%v, want %d resolved", snap.userID, snap.userResolved, userID)

View file

@ -106,26 +106,27 @@ func (r *Router) enqueueLoginMessageBootstrap(ctx context.Context, msg domain.Me
// publishBootstrapAfterBaseline runs only from the ordered post-response plan.
// It must never be called while the baseline rpc_result is merely encoded or
// queued, otherwise the bootstrap update can overtake that baseline on wire.
func (r *Router) publishBootstrapAfterBaseline(ctx context.Context, userID int64) {
func (r *Router) publishBootstrapAfterBaseline(ctx context.Context, userID int64) bool {
if r.deps.BootstrapUpdates == nil || userID == 0 {
return
return false
}
authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx)
sessionID, hasSessionID := SessionIDFrom(ctx)
if !hasAuthKeyID || !hasSessionID {
return
return false
}
cbCtx, cancel := context.WithTimeout(ctx, updatesDeliveryPhaseTimeout)
defer cancel()
ready, err := r.deps.BootstrapUpdates.MarkReadyForSession(cbCtx, userID, authKeyID, sessionID)
if err != nil {
r.log.Warn("mark bootstrap updates ready", zap.Int64("user_id", userID), zap.Int64("session_id", sessionID), zap.Error(err))
return
return false
}
if ready == 0 {
return
return true
}
r.publishReadyBootstrapUpdates(cbCtx, ready, defaultBootstrapLease, r.log.Named("bootstrap"))
return true
}
func (r *Router) publishReadyBootstrapUpdates(ctx context.Context, batch int, leaseTimeout time.Duration, log *zap.Logger) int {

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
@ -103,7 +104,7 @@ func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, use
peers = append(peers, peer)
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil {
if u, ok := item.(*tg.User); ok && u != nil && !u.Deleted {
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
}
}
@ -119,9 +120,13 @@ func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, use
if len(byPeer) == 0 {
return
}
applyBotVerificationIconsFromMap(users, chats, byPeer)
}
func applyBotVerificationIconsFromMap(users []tg.UserClass, chats []tg.ChatClass, byPeer map[domain.Peer]domain.CustomVerification) {
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil {
if !ok || u == nil || u.Deleted {
continue
}
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
@ -152,18 +157,29 @@ func (r *Router) botVerificationMap(ctx context.Context, peers []domain.Peer) ma
if r.deps.BotVerifications == nil || len(peers) == 0 {
return nil
}
_, verifications := r.peerIdentityMaps(ctx, peers, false, true)
return verifications
}
func (r *Router) loadBotVerificationMap(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
if len(peers) == 1 {
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peers[0])
if err != nil || mark.IconDocumentID <= 0 {
return nil
if err != nil {
if errors.Is(err, domain.ErrCustomVerificationNotFound) {
return map[domain.Peer]domain.CustomVerification{}, nil
}
return nil, err
}
return map[domain.Peer]domain.CustomVerification{peers[0]: mark}
if mark.IconDocumentID <= 0 {
return map[domain.Peer]domain.CustomVerification{}, nil
}
return map[domain.Peer]domain.CustomVerification{peers[0]: mark}, nil
}
byPeer, err := r.deps.BotVerifications.PeerVerificationBatch(ctx, peers)
if err != nil {
return nil
return nil, err
}
return byPeer
return byPeer, nil
}
// peerBotVerificationIcon resolves just the icon for one peer, for the update
@ -189,7 +205,7 @@ func applyBotVerificationIconToUsers(users []tg.UserClass, userID, icon int64) {
return
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil && u.ID == userID {
if u, ok := item.(*tg.User); ok && u != nil && !u.Deleted && u.ID == userID {
u.SetBotVerificationIcon(icon)
}
}

View file

@ -59,6 +59,7 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
callback.ClientSession = clientSessionMetadataFromContext(ctx)
botUserID := callback.BotUserID
// 内置进程内service bot 分支:@verifybot 这类 bot 没有 MTProto session、也没有

View file

@ -128,6 +128,19 @@ func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.Mess
results := r.inlines.registerCachedContext(ctx, now, bot.ID, userID, peer, cached)
return r.tgBotInlineResults(ctx, userID, results), nil
}
if service := r.deps.ServiceBotInlineResults; service != nil && service.HandlesInlineBot(bot.ID) {
results, handled, err := service.OnInlineQuery(ctx, bot.ID, userID, req.Query, req.Offset)
if err != nil {
return nil, internalErr()
}
if handled {
if len(results.Results) > domain.MaxBotInlineResults {
return nil, internalErr()
}
registered := r.inlines.registerCachedContext(ctx, now, bot.ID, userID, peer, results)
return r.tgBotInlineResults(ctx, userID, registered), nil
}
}
queryID, pending := r.inlines.registerWithCacheKeyContext(ctx, now, bot.ID, userID, peer, cacheKey)
defer r.inlines.deregisterIfUnansweredContext(ctx, queryID)

View file

@ -33,6 +33,43 @@ type inlineBotRPCTestFixture struct {
document domain.Document
}
type builtinGifCatalogRPCSource struct{ doc domain.Document }
func (s builtinGifCatalogRPCSource) ListGifCatalog(context.Context, bool) ([]domain.GifCatalogEntry, error) {
return []domain.GifCatalogEntry{{ID: 91, Title: "Wave", DocumentID: s.doc.ID, Enabled: true}}, nil
}
func (s builtinGifCatalogRPCSource) GetDocuments(context.Context, []int64) ([]domain.Document, error) {
return []domain.Document{s.doc}, nil
}
func TestBuiltinGifInlineQueryAcceptsGlobalEmptyPeerAndRegistersQuery(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
botStore := memory.NewBotStore(users)
owner, err := users.Create(ctx, domain.User{AccessHash: 7001, Phone: "15550007001", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
doc := domain.Document{ID: 901, AccessHash: 902, DCID: 2, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAnimated}, {Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1}}}
bots := botsapp.NewService(users, botStore, memory.NewMessageStore(memory.NewDialogStore()), botsapp.WithGifCatalogSource(builtinGifCatalogRPCSource{doc: doc}))
router := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Users: appusers.NewService(users), Bots: bots, ServiceBotInlineResults: bots}, zaptest.NewLogger(t), clock.System)
got, err := router.onMessagesGetInlineBotResults(WithUserID(ctx, owner.ID), &tg.MessagesGetInlineBotResultsRequest{Bot: inputUser(domain.GifBotUser()), Peer: &tg.InputPeerEmpty{}, Query: "wave"})
if err != nil {
t.Fatalf("global @gif query: %v", err)
}
if got.QueryID == 0 || len(got.Results) != 1 {
t.Fatalf("results = query_id %d len %d", got.QueryID, len(got.Results))
}
media, ok := got.Results[0].(*tg.BotInlineMediaResult)
if !ok {
t.Fatalf("result type = %T", got.Results[0])
}
wireDoc, ok := media.Document.(*tg.Document)
if !ok || wireDoc.ID != doc.ID {
t.Fatalf("document = %#v", media.Document)
}
}
func newInlineBotRPCTestFixture(t *testing.T) inlineBotRPCTestFixture {
t.Helper()
ctx := context.Background()
@ -844,8 +881,8 @@ func TestInlineBotArticleTextChannelRoundTrip(t *testing.T) {
}
editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID}
editReq.SetMessage("inline group edited")
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "Done", Data: []byte("v2")}},
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
Buttons: []tg.KeyboardInlineButton{{Text: "Done", Type: &tg.InlineButtonTypeCallback{Data: []byte("v2")}}},
}}})
if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok {
t.Fatalf("channel inline edit = %v,%v, want true,nil", ok, err)
@ -2065,12 +2102,13 @@ func assertTGInlineReplyMarkup(t *testing.T, msg *tg.Message, wantText string, w
if len(markup.Rows) != 1 || len(markup.Rows[0].Buttons) != 1 {
t.Fatalf("reply_markup rows = %+v, want one callback button", markup.Rows)
}
button, ok := markup.Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
button := markup.Rows[0].Buttons[0]
callback, ok := button.Type.(*tg.InlineButtonTypeCallback)
if !ok {
t.Fatalf("reply_markup button = %T, want callback", markup.Rows[0].Buttons[0])
t.Fatalf("reply_markup button type = %T, want callback", button.Type)
}
if button.Text != wantText || !bytes.Equal(button.Data, wantData) {
t.Fatalf("reply_markup button = %q/%v, want %q/%v", button.Text, button.Data, wantText, wantData)
if button.Text != wantText || !bytes.Equal(callback.Data, wantData) {
t.Fatalf("reply_markup button = %q/%v, want %q/%v", button.Text, callback.Data, wantText, wantData)
}
}
@ -2119,8 +2157,8 @@ func inlineArticleResult(id, message string) tg.InputBotInlineResultClass {
func inlineArticleResultWithCallback(id, message, button string, data []byte) tg.InputBotInlineResultClass {
result := inlineArticleResult(id, message).(*tg.InputBotInlineResult)
msg := result.SendMessage.(*tg.InputBotInlineMessageText)
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: button, Data: data}},
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
Buttons: []tg.KeyboardInlineButton{{Text: button, Type: &tg.InlineButtonTypeCallback{Data: data}}},
}}})
return result
}
@ -2308,8 +2346,8 @@ func inlineContactResult(id, phone, first, last, vcard string) *tg.InputBotInlin
func inlineContactResultWithCallback(id, phone, first, last string, data []byte) tg.InputBotInlineResultClass {
result := inlineContactResult(id, phone, first, last, "BEGIN:VCARD\nFN:"+first+" "+last+"\nEND:VCARD")
msg := result.SendMessage.(*tg.InputBotInlineMessageMediaContact)
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "Contact", Data: data}},
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
Buttons: []tg.KeyboardInlineButton{{Text: "Contact", Type: &tg.InlineButtonTypeCallback{Data: data}}},
}}})
return result
}

View file

@ -594,7 +594,7 @@ func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsReq
if err != nil {
return nil, internalErr()
}
if req.UserID == nil || req.Button == nil {
if req.UserID == nil || req.Button.Type == nil {
return nil, buttonDataInvalidErr()
}
if r.deps.Bots == nil {
@ -622,7 +622,7 @@ func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsReq
return &tg.BotsRequestedButton{WebappReqID: saved.WebAppReqID}, nil
}
func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (tg.KeyboardButtonClass, error) {
func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (*tg.KeyboardButton, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
@ -761,21 +761,20 @@ func (r *Router) tgBotPreviewMedia(ctx context.Context, item domain.BotAppPrevie
return out
}
func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButtonClass) (domain.BotRequestedWebViewButton, error) {
func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButton) (domain.BotRequestedWebViewButton, error) {
var out domain.BotRequestedWebViewButton
out.BotUserID = botUserID
switch b := button.(type) {
case *tg.InputKeyboardButtonRequestPeer:
out.Text = strings.TrimSpace(button.Text)
switch b := button.Type.(type) {
case *tg.InputButtonTypeRequestPeer:
out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text)
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
out.MaxQuantity = b.MaxQuantity
out.NameRequested = b.NameRequested
out.UsernameRequested = b.UsernameRequested
out.PhotoRequested = b.PhotoRequested
case *tg.KeyboardButtonRequestPeer:
case *tg.ButtonTypeRequestPeer:
out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text)
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
out.MaxQuantity = b.MaxQuantity
default:
@ -800,13 +799,10 @@ func requestPeerTypeName(peerType tg.RequestPeerTypeClass) string {
}
}
func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.KeyboardButtonClass {
return &tg.KeyboardButtonRequestPeer{
Text: button.Text,
ButtonID: button.ButtonID,
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
MaxQuantity: button.MaxQuantity,
}
func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) *tg.KeyboardButton {
return &tg.KeyboardButton{Text: button.Text, Type: &tg.ButtonTypeRequestPeer{
ButtonID: button.ButtonID, PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter), MaxQuantity: button.MaxQuantity,
}}
}
func tgRequestPeerType(kind string) tg.RequestPeerTypeClass {

View file

@ -252,9 +252,9 @@ func TestBotsLongtailCommercialAndSettingsStubs(t *testing.T) {
}
if _, err := f.router.onBotsRequestWebViewButton(botCtx, &tg.BotsRequestWebViewButtonRequest{
UserID: inputUser(f.owner),
Button: &tg.KeyboardButtonSimpleWebView{
Button: tg.KeyboardButton{
Text: "Open",
URL: "https://example.com/app",
Type: &tg.ButtonTypeSimpleWebView{URL: "https://example.com/app"},
},
}); !tgerr.Is(err, "BUTTON_DATA_INVALID") {
t.Fatalf("request webview button err = %v, want BUTTON_DATA_INVALID", err)

View file

@ -335,10 +335,11 @@ func (s *channelFanoutShard) signalEligibleOverflow() {
}
// channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次,
// 用于跨全部 recipient 一次性预热每 viewer 的用户投影fan-out 模板化O(owner))。可选:为 nil
// 时 build 仍逐 viewer 解析(行为不变)。在 worker goroutine 内串行执行,与 build 共享同一
// viewerPeerCache无跨 goroutine 竞态。
type channelFanoutPrefetch func(ctx context.Context, viewers []int64)
// 用于跨全部 recipient 一次性预热每 viewer 的用户投影fan-out 模板化O(owner))。为 nil
// 表示该 payload 不含需要预热的 user envelope。在 worker goroutine 内串行执行,与 build 共享同一
// viewerPeerCache无跨 goroutine 竞态。返回 false 表示批量预热失败worker 必须 fail-closed
// 不能静默退回逐 viewer 投影。
type channelFanoutPrefetch func(ctx context.Context, viewers []int64) bool
// channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC按 channelID 分片串行处理。
type channelFanoutDispatcher struct {
@ -881,13 +882,22 @@ func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob)
recipients := r.channelFanoutRecipients(ctx, job.scope, job.channelID, job.recipients)
// 预热跨 viewer 用户投影fan-out 模板化):在逐 viewer build 之前一次性算好每 recipient 的
// 投影并预热共享 cache使 build 只命中缓存、不再 O(viewer) 逐个 ForViewer。覆盖 recipients +
// 兜底 origin无在线 recipient 时 build 会回退给 origin。失败/未实现时静默退化为逐 viewer。
// 兜底 origin无在线 recipient 时 build 会回退给 origin。失败时禁止构造真实 payload
// 改发 viewer-independent too-long nudge让客户端从 durable difference 恢复。
if job.prefetch != nil {
viewers := recipients
if job.originUserID != 0 {
viewers = append(append(make([]int64, 0, len(recipients)+1), recipients...), job.originUserID)
}
job.prefetch(ctx, viewers)
if !job.prefetch(ctx, viewers) {
r.log.Warn("channel fanout prefetch failed; replacing online payload with recovery nudge",
zap.Int64("channel_id", job.channelID),
zap.Int("pts", job.pts),
zap.Int("viewers", len(viewers)),
)
r.recoverFailedChannelFanoutPrefetch(pushCtx, job, recipients)
return
}
}
seen := make(map[int64]struct{}, len(recipients))
pushed := false
@ -923,27 +933,82 @@ func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob)
}
}
func (r *Router) recoverFailedChannelFanoutPrefetch(ctx context.Context, job channelFanoutJob, recipients []int64) {
if job.channelID == 0 || job.pts <= 0 {
return
}
targets := append([]int64(nil), recipients...)
if job.originUserID != 0 {
targets = append(targets, job.originUserID)
}
targets = uniquePeerIDs(targets)
delivered := make(map[int64]struct{}, len(targets))
date := int(r.clock.Now().Unix())
tooLong := &tg.UpdateChannelTooLong{ChannelID: job.channelID}
tooLong.SetPts(job.pts)
updates := &tg.Updates{
Updates: []tg.UpdateClass{tooLong},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
}
for _, userID := range targets {
if userID == 0 {
continue
}
select {
case <-ctx.Done():
return
default:
}
r.pushUserUpdates(ctx, userID, updates)
delivered[userID] = struct{}{}
}
// Explicit monoforum/suggested-post recipients are the full authorized
// audience. Member/message-box scopes may have additional online viewers
// beyond the full-payload cap, so nudge that recovery audience too.
switch job.scope {
case channelFanoutMembers:
r.nudgeBeyondCapChannelMembers(ctx, job.channelID, job.pts, delivered)
case channelFanoutMessageBox:
r.nudgeBeyondCapChannelMessageAudience(ctx, job.channelID, job.pts, delivered)
}
}
// prefetchChannelFanoutUsers 跨全部 recipient 一次性投影 owner 用户fan-out 模板化O(owner)
// 把结果按 viewer 预热进共享 cache之后每 viewer 的 build 只命中缓存,不再逐 viewer ForViewer。
// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 未实现 BatchViewerUsersResolver 或解析
// 失败时静默跳过——build 回退逐 viewer 解析,行为不变,仅退化为旧的 O(viewer) 成本。
func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) {
if cache == nil || len(viewers) == 0 || len(ownerIDs) == 0 || r.deps.Users == nil {
return
// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 必须实现 BatchViewerUsersResolver
// 缺能力、解析失败或 envelope 不完整时返回 false由 worker fail-closed禁止逐 viewer 回退。
func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) bool {
viewers = uniquePeerIDs(viewers)
ownerIDs = uniquePeerIDs(ownerIDs)
if len(viewers) == 0 || len(ownerIDs) == 0 {
return true
}
if cache == nil || r.deps.Users == nil {
return false
}
resolver, ok := r.deps.Users.(BatchViewerUsersResolver)
if !ok {
return
return false
}
byViewer, err := resolver.ByIDsForViewers(ctx, viewers, ownerIDs)
if err != nil {
r.log.Warn("channel fanout user prefetch failed; falling back to per-viewer projection",
r.log.Warn("channel fanout user prefetch failed",
zap.Int("viewers", len(viewers)), zap.Int("owners", len(ownerIDs)), zap.Error(err))
return
return false
}
for viewer, users := range byViewer {
cache.primeUsers(viewer, users)
for _, viewer := range viewers {
if missingID, missing := missingProjectedUserID(ownerIDs, byViewer[viewer]); missing {
r.log.Warn("channel fanout user prefetch returned an incomplete envelope",
zap.Int64("viewer_user_id", viewer),
zap.Int64("missing_user_id", missingID),
zap.Int("owners", len(ownerIDs)))
return false
}
cache.primeExpectedUsers(viewer, ownerIDs, byViewer[viewer])
}
return true
}
// channelMessageFanoutOwnerIDs 收集一条频道消息 fan-out 会下发到 Users 数组里的全部 owner 用户 id
@ -998,9 +1063,12 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
func(bgCtx context.Context, viewers []int64) bool {
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
return false
}
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
// privacy bot 在 send 时被 SkipDeliveryUserIDs 排除(命令/@/回复以外的消息不可见)。
@ -1021,13 +1089,19 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) {
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID})
projectionPeers := monoforumProjectionPeers(mono.ID, mono.LinkedMonoforumID, ownerIDs)
var overlays *monoforumPeerOverlays
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
func(bgCtx context.Context, viewers []int64) bool {
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
return false
}
overlays = r.loadMonoforumPeerOverlays(bgCtx, projectionPeers)
return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.monoforumDeliveryUpdates(bgCtx, viewerUserID, mono, savedPeer, res)
return r.monoforumDeliveryUpdatesWithPeerCacheAndOverlays(bgCtx, viewerUserID, mono, savedPeer, res, fanoutCache, overlays)
})
}
@ -1099,9 +1173,12 @@ func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUser
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, nudgePts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
func(bgCtx context.Context, viewers []int64) bool {
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
return false
}
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelEditMessageUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, res, fanoutCache, usernames)
@ -1119,9 +1196,12 @@ func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID,
var usernames map[domain.Peer][]domain.Username
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, channelID, pts, recipients,
int64(len(results))*(64<<10),
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
func(bgCtx context.Context, viewers []int64) bool {
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
return false
}
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelMessagesUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache, usernames)

View file

@ -249,8 +249,9 @@ func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) {
var gotViewers []int64
job := fanoutTestJob([]int64{2001, 2002}, 5, 99, nil)
job.prefetch = func(_ context.Context, viewers []int64) {
job.prefetch = func(_ context.Context, viewers []int64) bool {
gotViewers = append([]int64(nil), viewers...)
return true
}
// deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001 2002]origin=5 兜底追加。
r.channelFanout.Enqueue(context.Background(), job)
@ -266,6 +267,36 @@ func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) {
}
}
func TestChannelFanoutJobPrefetchFailureSendsRecoveryNudge(t *testing.T) {
sessions := &captureSessions{}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
built := make(map[int64]bool)
job := fanoutTestJob([]int64{20}, 10, 0, built)
job.pts = 7
job.prefetch = func(context.Context, []int64) bool { return false }
r.runChannelFanoutJob(context.Background(), job)
if len(built) != 0 {
t.Fatalf("build called after prefetch failure: %v", built)
}
if got := sessions.pushedUserIDs(); len(got) != 2 || got[0] != 20 || got[1] != 10 {
t.Fatalf("pushes after prefetch failure = %v, want recovery nudge to recipient 20 and origin 10", got)
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("recovery payload = %#v, want one UpdateChannelTooLong", sessions.lastUserPush())
}
nudge, ok := updates.Updates[0].(*tg.UpdateChannelTooLong)
if !ok {
t.Fatalf("recovery update = %T, want UpdateChannelTooLong", updates.Updates[0])
}
pts, present := nudge.GetPts()
if !present || nudge.ChannelID != 1001 || pts != 7 {
t.Fatalf("recovery nudge = %+v pts_present=%v, want channel=1001 pts=7", nudge, present)
}
}
// editFanoutTestResult 构造一条覆盖两容器的 EditChannelMessageResult主容器(Event/Message)带
// sender A + reply B服务消息容器(ServiceEvent/ServiceMessage)带 sender C + Action.UserIDs=[D]。
func editFanoutTestResult(eventPts, servicePts int) domain.EditChannelMessageResult {
@ -326,6 +357,16 @@ type prefetchRecordingUsersService struct {
gotViewers []int64
gotOwnerIDs []int64
forViewerCall int
byIDsCalls int
omitViewer int64
omitOwner int64
}
func (s *prefetchRecordingUsersService) ByIDs(ctx context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) {
s.mu.Lock()
s.byIDsCalls++
s.mu.Unlock()
return s.mapUsersService.ByIDs(ctx, viewerUserID, userIDs)
}
func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewerUserIDs, userIDs []int64) (map[int64][]domain.User, error) {
@ -336,11 +377,46 @@ func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewe
s.mu.Unlock()
out := make(map[int64][]domain.User, len(viewerUserIDs))
for _, v := range viewerUserIDs {
out[v] = nil
if v == s.omitViewer {
continue
}
for _, id := range userIDs {
if id == s.omitOwner {
continue
}
user, ok := s.mapUsersService.users[id]
if !ok {
user = domain.User{ID: id}
}
out[v] = append(out[v], user)
}
}
return out, nil
}
func (s *prefetchRecordingUsersService) snapshot() (forViewerCall int, viewers, ownerIDs []int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.forViewerCall, append([]int64(nil), s.gotViewers...), append([]int64(nil), s.gotOwnerIDs...)
}
func TestPrefetchChannelFanoutUsersRejectsMissingViewersAndOwners(t *testing.T) {
users := &prefetchRecordingUsersService{omitViewer: 3002, mapUsersService: mapUsersService{users: map[int64]domain.User{
2001: {ID: 2001, FirstName: "must not scalar load"},
2002: {ID: 2002, FirstName: "must not scalar load"},
}}}
r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
cache := newViewerPeerCache(r)
if r.prefetchChannelFanoutUsers(context.Background(), cache, []int64{3001, 3002}, []int64{2001, 2002}) {
t.Fatal("prefetch accepted a response that omitted an entire viewer")
}
users.omitViewer = 0
users.omitOwner = 2002
if r.prefetchChannelFanoutUsers(context.Background(), cache, []int64{3001}, []int64{2001, 2002}) {
t.Fatal("prefetch accepted a response that omitted an owner")
}
}
// TestChannelEditMessageFanoutInvokesPrefetchenqueueChannelEditMessageFanout 在逐 viewer build
// 前用「channelEditMessageFanoutOwnerIDs(res) + recipients+origin」预热dispatcher 未启动→同步
// 回退prefetch 同步执行)。锁定 edit 路径接入了 O(owner) 预热而非逐 viewer 投影。
@ -353,19 +429,20 @@ func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) {
res := editFanoutTestResult(5, 6)
r.enqueueChannelEditMessageFanout(context.Background(), 5, res)
if users.forViewerCall != 1 {
t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", users.forViewerCall)
forViewerCall, viewers, ownerIDs := users.snapshot()
if forViewerCall != 1 {
t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", forViewerCall)
}
gotViewers := ownerIDSet(users.gotViewers)
gotViewers := ownerIDSet(viewers)
for _, want := range []int64{3001, 3002, 5} {
if !gotViewers[want] {
t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", users.gotViewers, want)
t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", viewers, want)
}
}
gotOwners := ownerIDSet(users.gotOwnerIDs)
gotOwners := ownerIDSet(ownerIDs)
for _, want := range []int64{2001, 2002, 2003, 2004} {
if !gotOwners[want] {
t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", users.gotOwnerIDs, want)
t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", ownerIDs, want)
}
}
if registry.batchCalls != 1 || registry.peerCalls != 0 {

View file

@ -61,7 +61,10 @@ func TestChannelMessageFanoutSkipsPrivacyBotOnlinePush(t *testing.T) {
sessions := &captureSessions{
channelMembers: map[int64][]int64{created.Channel.ID: {1002, 1003}},
}
r := New(Config{}, Deps{Channels: channelService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
users := &prefetchRecordingUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "sender"},
}}}
r := New(Config{}, Deps{Channels: channelService, Sessions: sessions, Users: users}, zaptest.NewLogger(t), clock.System)
// 复核前置:修复前后 channelFanoutRecipients 都会把 1003 列进 recipients(在线活跃成员),
// 漏洞/修复的差异在 build 是否对它返回 nil。

View file

@ -39,13 +39,33 @@ func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCr
return nil, channelInvalidErr(err)
}
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
updates, err := r.channelCreationResponseUpdates(ctx, userID, res)
if err != nil {
return nil, err
}
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
return updates, nil
}
// channelCreationResponseUpdates adds the response-only message mapping TDLib
// requires to recognize a channels.createChannel result. The mapping is never
// reused by fan-out or difference; the create service message remains the sole
// durable, PTS-bearing fact.
func (r *Router) channelCreationResponseUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) (*tg.Updates, error) {
if res.Message.ID <= 0 || res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionCreate {
return nil, internalErr()
}
updates := r.channelOperationUpdates(ctx, viewerUserID, res)
if updates == nil {
return nil, internalErr()
}
mapping := &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomNonZeroInt64()}
updates.Updates = append([]tg.UpdateClass{mapping}, updates.Updates...)
return updates, nil
}
func validateChannelsCreateChannelOptions(req *tg.ChannelsCreateChannelRequest) error {
if req == nil {
return inputRequestInvalidErr()
@ -168,6 +188,9 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
full := cached.full
if err := r.applyWelcomeMessagesToFullChat(ctx, ref.ID, &full); err != nil {
return nil, err
}
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, ref.ID, &full); err != nil {
return nil, err
}
@ -191,6 +214,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
return nil, err
}
full := tgChannelFull(view, r.cfg.PublicBaseURL)
r.applyChannelStatsCapability(full)
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
// 注Bots 过滤实际会返回群内 botTestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
// (此前审计误判为死代码,已由单测纠正——勿删。)
@ -217,6 +241,9 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
chats: append([]tg.ChatClass(nil), chats...),
userIDs: userIDs,
}, loadEpoch)
if err := r.applyWelcomeMessagesToFullChat(ctx, view.Channel.ID, full); err != nil {
return nil, err
}
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, view.Channel.ID, full); err != nil {
return nil, err
}
@ -235,6 +262,23 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
}, nil
}
func (r *Router) applyWelcomeMessagesToFullChat(ctx context.Context, channelID int64, full tg.ChatFullClass) error {
if r.deps.WelcomeMessages == nil || channelID <= 0 || full == nil {
return nil
}
hasAny, err := r.deps.WelcomeMessages.HasAny(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
if err != nil {
return internalErr()
}
switch value := full.(type) {
case *tg.ChannelFull:
value.HasWelcomeMessages = hasAny
case *tg.ChatFull:
value.HasWelcomeMessages = hasAny
}
return nil
}
type channelReadModelResolver interface {
GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
}
@ -290,27 +334,48 @@ func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSen
}
}
chats = []tg.ChatClass{tgChannelChatForView(userID, view)}
// 以「当前频道/群本身」发言广播频道自帖、匿名管理员等canCurrentChannelSendAs 判定)。
if canCurrentChannelSendAs(view) {
peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}})
}
// 以「用户自己拥有的其它广播频道」身份在本群发言。非本群关联频道的个人频道需会员
// premium_required对齐官方仅本群的 linked 讨论频道免会员),客户端据此置灰/引导开会员,
// 服务端在发送侧用 PremiumActiveAt 兜底门控。
if owned, err := r.deps.Channels.ListSendAsChannels(ctx, userID); err == nil && len(owned) > 0 {
extras := make([]domain.Channel, 0, len(owned))
owned, ownedErr := r.deps.Channels.ListSendAsChannels(ctx, userID)
if req.ForPaidReactions {
// Paid reaction identities are self plus currently owned/postable
// broadcast channels. They are not message send-as candidates and do
// not carry the unrelated premium_required gate.
seen := make(map[int64]struct{}, len(owned))
for _, ch := range owned {
if ch.ID == 0 || ch.ID == view.Channel.ID {
if ch.ID == 0 || ch.Deleted || !ch.Broadcast || ch.CreatorUserID != userID {
continue
}
sendAs := tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}}
if ch.ID != view.Channel.LinkedChatID {
sendAs.PremiumRequired = true
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}})
if ch.ID != view.Channel.ID {
chats = append(chats, tgChannels(userID, []domain.Channel{ch})...)
}
peers = append(peers, sendAs)
extras = append(extras, ch)
}
chats = append(chats, tgChannels(userID, extras)...)
} else {
// 以「当前频道/群本身」发言广播频道自帖、匿名管理员等canCurrentChannelSendAs 判定)。
if canCurrentChannelSendAs(view) {
peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}})
}
// 以「用户自己拥有的其它广播频道」身份在本群发言。非本群关联频道的个人频道需会员
// premium_required对齐官方仅本群的 linked 讨论频道免会员),客户端据此置灰/引导开会员,
// 服务端在发送侧用 PremiumActiveAt 兜底门控。
if ownedErr == nil && len(owned) > 0 {
extras := make([]domain.Channel, 0, len(owned))
for _, ch := range owned {
if ch.ID == 0 || ch.ID == view.Channel.ID {
continue
}
sendAs := tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}}
if ch.ID != view.Channel.LinkedChatID {
sendAs.PremiumRequired = true
}
peers = append(peers, sendAs)
extras = append(extras, ch)
}
chats = append(chats, tgChannels(userID, extras)...)
}
}
}
out := &tg.ChannelsSendAsPeers{

View file

@ -0,0 +1,97 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestChannelsCreateChannelResponseCarriesTdlibMessageMappingOnlyForCaller(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{
AccessHash: 88001,
Phone: "15550088001",
FirstName: "Owner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sessions := &captureSessions{onlineUserIDs: []int64{owner.ID}}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(memory.NewChannelStore()),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
tests := []struct {
name string
req *tg.ChannelsCreateChannelRequest
}{
{name: "broadcast", req: &tg.ChannelsCreateChannelRequest{Title: "TDLib broadcast", Broadcast: true}},
{name: "megagroup", req: &tg.ChannelsCreateChannelRequest{Title: "TDLib group", Megagroup: true}},
{name: "forum", req: &tg.ChannelsCreateChannelRequest{Title: "TDLib forum", Megagroup: true, Forum: true}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sessions.clearMessages()
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), test.req)
if err != nil {
t.Fatalf("create channel: %v", err)
}
updates, ok := created.(*tg.Updates)
if !ok || len(updates.Updates) != 3 {
t.Fatalf("response = %T %+v, want mapping, create message, and channel refresh", created, created)
}
mapping, ok := updates.Updates[0].(*tg.UpdateMessageID)
if !ok || mapping.ID <= 0 || mapping.RandomID == 0 {
t.Fatalf("mapping = %#v, want positive message id and non-zero random id", updates.Updates[0])
}
create, ok := updates.Updates[1].(*tg.UpdateNewChannelMessage)
if !ok || create.Pts != domain.FirstChannelEventPts || create.PtsCount != 1 {
t.Fatalf("create update = %#v, want pts=2 pts_count=1", updates.Updates[1])
}
service, ok := create.Message.(*tg.MessageService)
if !ok || service.ID != mapping.ID {
t.Fatalf("create service = %#v, want mapped id %d", create.Message, mapping.ID)
}
if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok {
t.Fatalf("create action = %T, want messageActionChannelCreate", service.Action)
}
if refresh, ok := updates.Updates[2].(*tg.UpdateChannel); !ok || refresh.ChannelID == 0 {
t.Fatalf("refresh = %#v, want updateChannel", updates.Updates[2])
}
pushed, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(pushed.Updates) != 2 {
t.Fatalf("fan-out = %T %+v, want create message and channel refresh only", sessions.lastUserPush(), sessions.lastUserPush())
}
for _, update := range pushed.Updates {
if _, ok := update.(*tg.UpdateMessageID); ok {
t.Fatalf("response-only updateMessageID leaked into fan-out: %+v", pushed.Updates)
}
}
})
}
}
func TestChannelCreationResponseRejectsNonCreationResult(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
for _, result := range []domain.CreateChannelResult{
{},
{Message: domain.ChannelMessage{ID: 1}},
{Message: domain.ChannelMessage{ID: 1, Action: &domain.ChannelMessageAction{Type: domain.ChannelActionChatAddUser}}},
} {
if updates, err := r.channelCreationResponseUpdates(context.Background(), 1, result); err == nil || updates != nil {
t.Fatalf("invalid creation result = %+v produced updates=%+v err=%v", result, updates, err)
}
}
}

View file

@ -102,6 +102,57 @@ func TestChannelsGetParticipantsUsesSingleBatchUserLookup(t *testing.T) {
}
}
func TestChannelsGetParticipantsRetainsDeletedAccountTombstone(t *testing.T) {
ctx := context.Background()
owner := domain.User{ID: 1, AccessHash: 101, Phone: "15550002131", FirstName: "Owner"}
member := domain.User{ID: 2, AccessHash: 102, Phone: "15550002132", FirstName: "Member"}
users := mapUsersService{users: map[int64]domain.User{
owner.ID: owner,
member.ID: member,
}}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: users,
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
},
Title: "Deleted Account Membership Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
users.users[member.ID] = domain.User{ID: member.ID, Deleted: true, DeletedAt: 1_800_000_000}
got, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 20,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
list := got.(*tg.ChannelsChannelParticipants)
if len(list.Participants) != 2 {
t.Fatalf("participants = %+v, want owner and retained deleted member", list.Participants)
}
for _, item := range list.Users {
u, ok := item.(*tg.User)
if !ok || u.ID != member.ID {
continue
}
if !u.Deleted || u.AccessHash != 0 || u.Phone != "" || u.FirstName != "" || u.LastName != "" || u.Username != "" || u.Photo != nil || u.Status != nil {
t.Fatalf("deleted member projection leaked profile state: %+v", u)
}
return
}
t.Fatalf("users = %+v, want retained deleted member tombstone", list.Users)
}
func TestChannelsGetParticipantsValidatesHashAfterParticipantAccessCheck(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()

View file

@ -618,8 +618,8 @@ func (r *Router) enqueueChannelWallpaperFanout(ctx context.Context, originUserID
ownerIDs := channelMessageFanoutOwnerIDs(sendRes, nil)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
func(bgCtx context.Context, viewers []int64) {
r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
func(bgCtx context.Context, viewers []int64) bool {
return r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelWallpaperUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache)

View file

@ -344,7 +344,10 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
cache := newViewerPeerCache(r)
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, res, cache)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
// Durable membership is not a realtime audience. Production fan-out derives
// online active members from the session fabric; offline members converge via
// the committed channel event/difference and must not pay payload-build cost.
r.pushChannelUpdates(ctx, userID, res.Channel.ID, nil, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
})
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil

View file

@ -100,6 +100,7 @@ func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSear
return nil, channelInvalidErr(err)
}
history = r.enrichChannelHistory(ctx, userID, history)
r.maybeEnqueueExpiredChannelWebPageResolves(userID, history.Messages)
result := tgChannelSearchPostsMessages(userID, history)
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
@ -267,15 +268,10 @@ func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetM
if err := r.checkFrozenChannelParticipants(ctx, userID, channelID); err != nil {
return nil, err
}
ids := make([]int, 0, len(req.ID))
for _, input := range req.ID {
id, ok := inputMessageBoxID(input)
if !ok || id <= 0 || id > domain.MaxMessageBoxID {
continue
}
ids = append(ids, id)
}
trace := newGetMessagesInputTrace(req.ID)
ids := trace.lookupIDs
if len(ids) == 0 {
r.logChannelGetMessagesTrace(ctx, channelID, trace, nil, &tg.MessagesMessages{})
return &tg.MessagesMessages{}, nil
}
history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, ids)
@ -287,6 +283,7 @@ func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetM
for _, msg := range history.Messages {
byID[msg.ID] = msg
}
r.maybeEnqueueExpiredChannelWebPageResolves(userID, history.Messages)
messages := make([]tg.MessageClass, 0, len(ids))
for _, id := range ids {
if msg, ok := byID[id]; ok {
@ -301,6 +298,7 @@ func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetM
Users: r.tgUsersForViewer(userID, history.Users), // viewer 补拉自己的消息(含置顶)须带 self
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
r.logChannelGetMessagesTrace(ctx, channelID, trace, history.Messages, result)
return result, nil
}

View file

@ -121,11 +121,15 @@ func TestChannelInputAccessHashIsValidatedRPC(t *testing.T) {
if got := len(mixedChats.(*tg.MessagesChats).Chats); got != 2 {
t.Fatalf("get channels mixed access_hash chats = %d, want two good refs", got)
}
if _, err := r.dialogFilterFromRequest(WithUserID(ctx, owner.ID), owner.ID, &tg.MessagesGetDialogsRequest{
dialogFilter, err := r.dialogFilterFromRequest(WithUserID(ctx, owner.ID), owner.ID, &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash},
Limit: 20,
}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") {
t.Fatalf("get dialogs offset bad access_hash err = %v, want CHANNEL_PRIVATE", err)
})
if err != nil {
t.Fatalf("get dialogs cursor with stale access_hash: %v", err)
}
if !dialogFilter.HasOffsetPeer || dialogFilter.OffsetPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
t.Fatalf("get dialogs cursor = %+v, want channel id without content authorization", dialogFilter)
}
if _, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash},
@ -525,13 +529,14 @@ func TestChannelsCreateChannelUnsupportedOptionsReturnExplicitErrors(t *testing.
}
}
func TestChannelsGetFullChannelCanSetUsernameOnlyForCreator(t *testing.T) {
func TestChannelsGetFullChannelProjectsManagementAndStatsCapabilities(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 56, Phone: "15550002211", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 57, Phone: "15550002212", FirstName: "Member"})
admin, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002213", FirstName: "Admin"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
r := New(Config{DC: 2}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
@ -572,23 +577,56 @@ func TestChannelsGetFullChannelCanSetUsernameOnlyForCreator(t *testing.T) {
t.Fatalf("owner get full channel: %v", err)
}
ownerChannelFull := ownerFull.FullChat.(*tg.ChannelFull)
if !ownerChannelFull.CanSetUsername || !ownerChannelFull.CanDeleteChannel {
t.Fatalf("owner full flags can_set_username=%v can_delete_channel=%v, want both true", ownerChannelFull.CanSetUsername, ownerChannelFull.CanDeleteChannel)
if !ownerChannelFull.CanSetUsername || !ownerChannelFull.CanDeleteChannel || !ownerChannelFull.CanViewStats {
t.Fatalf("owner full flags can_set_username=%v can_delete_channel=%v can_view_stats=%v, want all true", ownerChannelFull.CanSetUsername, ownerChannelFull.CanDeleteChannel, ownerChannelFull.CanViewStats)
}
if statsDC, ok := ownerChannelFull.GetStatsDC(); !ok || statsDC != 2 {
t.Fatalf("owner full stats_dc=(%d,%v), want (2,true)", statsDC, ok)
}
cachedOwnerFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input)
if err != nil {
t.Fatalf("owner get cached full channel: %v", err)
}
cachedOwnerChannelFull := cachedOwnerFull.FullChat.(*tg.ChannelFull)
if statsDC, ok := cachedOwnerChannelFull.GetStatsDC(); !cachedOwnerChannelFull.CanViewStats || !ok || statsDC != 2 {
t.Fatalf("cached owner full can_view_stats=%v stats_dc=(%d,%v), want true and (2,true)", cachedOwnerChannelFull.CanViewStats, statsDC, ok)
}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: input,
Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}},
Users: []tg.InputUserClass{
&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
&tg.InputUser{UserID: admin.ID, AccessHash: admin.AccessHash},
},
}); err != nil {
t.Fatalf("invite member: %v", err)
t.Fatalf("invite members: %v", err)
}
memberFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), input)
if err != nil {
t.Fatalf("member get full channel: %v", err)
}
memberChannelFull := memberFull.FullChat.(*tg.ChannelFull)
if memberChannelFull.CanSetUsername || memberChannelFull.CanDeleteChannel {
t.Fatalf("member full flags can_set_username=%v can_delete_channel=%v, want both false", memberChannelFull.CanSetUsername, memberChannelFull.CanDeleteChannel)
if memberChannelFull.CanSetUsername || memberChannelFull.CanDeleteChannel || memberChannelFull.CanViewStats {
t.Fatalf("member full flags can_set_username=%v can_delete_channel=%v can_view_stats=%v, want all false", memberChannelFull.CanSetUsername, memberChannelFull.CanDeleteChannel, memberChannelFull.CanViewStats)
}
if statsDC, ok := memberChannelFull.GetStatsDC(); ok {
t.Fatalf("member full stats_dc=(%d,true), want absent", statsDC)
}
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: input,
UserID: &tg.InputUser{UserID: admin.ID, AccessHash: admin.AccessHash},
AdminRights: tg.ChatAdminRights{ChangeInfo: true},
}); err != nil {
t.Fatalf("promote admin: %v", err)
}
adminFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, admin.ID), input)
if err != nil {
t.Fatalf("admin get full channel: %v", err)
}
adminChannelFull := adminFull.FullChat.(*tg.ChannelFull)
if statsDC, ok := adminChannelFull.GetStatsDC(); !adminChannelFull.CanViewStats || !ok || statsDC != 2 {
t.Fatalf("admin full can_view_stats=%v stats_dc=(%d,%v), want true and (2,true)", adminChannelFull.CanViewStats, statsDC, ok)
}
})
}

View file

@ -45,13 +45,20 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
}
return nil, channelInvalidErr(err)
}
diff, err = r.enrichChannelDifferenceStrict(ctx, userID, diff)
if err != nil {
r.log.Error("project durable channel difference users",
zap.Int64("viewer_user_id", userID),
zap.Int64("channel_id", channelID),
zap.Error(err))
return nil, internalErr()
}
if diff.Channel.Username != "" && diff.Self.Status != domain.ChannelMemberActive {
// Telegram's public-channel passive delivery is enabled only after a
// successful short-poll difference. The runtime subscription is renewed
// by subsequent polls and never creates membership/dialog/read state.
r.refreshPublicChannelSubscription(ctx, userID, channelID)
}
diff = r.enrichChannelDifference(ctx, userID, diff)
out := r.tgChannelDifference(ctx, userID, diff)
if linked, ok := r.linkedDiscussionChat(ctx, userID, channelID); ok {
switch value := out.(type) {

View file

@ -104,8 +104,8 @@ func TestChannelSendHistoryAndDifferenceRPC(t *testing.T) {
t.Fatalf("message id update = %#v, want id=3 random_id=99", sendUpdates.Updates[0])
}
newMsg, ok := sendUpdates.Updates[1].(*tg.UpdateNewChannelMessage)
if !ok || newMsg.Pts != 3 || newMsg.PtsCount != 1 {
t.Fatalf("new channel update = %#v, want pts=3", sendUpdates.Updates[1])
if !ok || newMsg.Pts != 4 || newMsg.PtsCount != 1 {
t.Fatalf("new channel update = %#v, want pts=4", sendUpdates.Updates[1])
}
msg := newMsg.Message.(*tg.Message)
if msg.PeerID.(*tg.PeerChannel).ChannelID != channel.ID || msg.Message != "hello channel" || !msg.Out {

View file

@ -95,6 +95,22 @@ func TestNormalizeClientInfoPreservesMetadataWithinLimits(t *testing.T) {
}
}
func TestClientSessionMetadataFromContextCarriesLanguageAndPhysicalSession(t *testing.T) {
var raw [8]byte
raw[0], raw[7] = 0x11, 0x77
ctx := WithClientInfo(context.Background(), ClientInfo{
SystemLangCode: "en-US", LangPack: "tdesktop", LangCode: "ru",
})
ctx = WithRawAuthKeyID(ctx, raw)
ctx = WithSessionID(ctx, 998877)
got := clientSessionMetadataFromContext(ctx)
if got.AuthKeyID != raw || got.SessionID != 998877 ||
got.SystemLangCode != "en-US" || got.LangPack != "tdesktop" || got.LangCode != "ru" ||
got.PreferredLanguage() != "ru" {
t.Fatalf("client session metadata = %+v", got)
}
}
func assertClientMetadataRunes(t *testing.T, field, value string, want int) {
t.Helper()
if got := utf8.RuneCountInString(value); got != want {

View file

@ -0,0 +1,50 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestMessagesSearchPhoneCallsDoesNotFallThroughToOrdinaryHistory(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
filter, err := r.messageFilterFromSearchRequest(context.Background(), 1001, &tg.MessagesSearchRequest{
Peer: &tg.InputPeerSelf{},
Filter: &tg.InputMessagesFilterPhoneCalls{Missed: true},
Limit: 50,
})
if err != nil {
t.Fatal(err)
}
if !filter.PhoneCallsOnly || !filter.MissedPhoneCallsOnly {
t.Fatalf("phone filter = %+v", filter)
}
if searchFilterNeedsMediaStore(&tg.InputMessagesFilterPhoneCalls{}) {
t.Fatal("phone-call filter must use message service-action search, not media search")
}
}
func TestCrossDialogReplyKeepsExplicitSourcePeer(t *testing.T) {
const senderID, destinationID, sourceID = int64(1001), int64(1002), int64(1003)
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
senderID: {ID: senderID, AccessHash: 11},
destinationID: {ID: destinationID, AccessHash: 22},
sourceID: {ID: sourceID, AccessHash: 33},
}}}, zaptest.NewLogger(t), clock.System)
input := &tg.InputReplyToMessage{ReplyToMsgID: 77}
input.SetReplyToPeerID(&tg.InputPeerUser{UserID: sourceID, AccessHash: 33})
input.SetQuoteText("source quote")
reply, err := r.messageReplyFromInput(context.Background(), senderID,
domain.Peer{Type: domain.PeerTypeUser, ID: destinationID}, input)
if err != nil {
t.Fatal(err)
}
if reply == nil || reply.MessageID != 77 || reply.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: sourceID}) {
t.Fatalf("cross-dialog reply = %+v", reply)
}
}

View file

@ -2,12 +2,18 @@ package rpc
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
"reflect"
"strings"
appchannels "telesrv/internal/app/channels"
appcontacts "telesrv/internal/app/contacts"
appprivacy "telesrv/internal/app/privacy"
@ -18,8 +24,6 @@ import (
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
func TestContactsSearchFindsUsers(t *testing.T) {
@ -187,6 +191,7 @@ func TestContactsEditCloseFriendsFanoutsCloseFriendStories(t *testing.T) {
Contacts: appcontacts.NewService(contactsStore, users),
Stories: appstories.NewService(storyStore),
Updates: appupdates.NewService(stateStore, updateStore),
Users: appusers.NewService(users),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
ownerAuth := [8]byte{1, 2, 3}
@ -321,6 +326,7 @@ func TestContactsBlockUnblockFanoutsStoryVisibilityChanges(t *testing.T) {
Contacts: appcontacts.NewService(contactsStore, users),
Stories: appstories.NewService(storyStore),
Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore),
Users: appusers.NewService(users),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
ownerAuth := [8]byte{7, 7, 1}
@ -475,6 +481,7 @@ func TestContactsSetBlockedReplacesStoryBlocklistFanouts(t *testing.T) {
Contacts: appcontacts.NewService(contactsStore, users),
Stories: appstories.NewService(storyStore),
Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore),
Users: appusers.NewService(users),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
ownerAuth := [8]byte{8, 8, 1}
@ -1558,14 +1565,22 @@ func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) {
Users: appusers.NewService(userStore),
Contacts: appcontacts.NewService(memory.NewContactStore(), userStore),
}, zaptest.NewLogger(t), clock.System)
bobCtx := WithUserID(ctx, bob.ID)
userFull, err := r.onUsersGetFullUser(bobCtx, &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash})
if err != nil {
t.Fatalf("users.getFullUser before block: %v", err)
}
if userFull.FullUser.Blocked || userFull.FullUser.BlockedMyStoriesFrom || !userFull.FullUser.Settings.BlockContact {
t.Fatalf("full user before block = %+v, want unblocked flags and block action", userFull.FullUser)
}
ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{
ok, err := r.onContactsBlock(bobCtx, &tg.ContactsBlockRequest{
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
})
if err != nil || !ok {
t.Fatalf("contacts.block = %v, %v", ok, err)
}
blocked, err := r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10})
blocked, err := r.onContactsGetBlocked(bobCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
if err != nil {
t.Fatalf("contacts.getBlocked: %v", err)
}
@ -1579,18 +1594,134 @@ func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) {
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID || user.Phone != "" {
t.Fatalf("blocked user = %#v, want alice with hidden phone", full.Users[0])
}
userFull, err = r.onUsersGetFullUser(bobCtx, &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash})
if err != nil {
t.Fatalf("users.getFullUser after block: %v", err)
}
if !userFull.FullUser.Blocked || !userFull.FullUser.BlockedMyStoriesFrom || userFull.FullUser.Settings.BlockContact {
t.Fatalf("full user after block = %+v, want blocked flags and no block action", userFull.FullUser)
}
aliceView, err := r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash})
if err != nil {
t.Fatalf("users.getFullUser for opposite owner: %v", err)
}
if aliceView.FullUser.Blocked || aliceView.FullUser.BlockedMyStoriesFrom || !aliceView.FullUser.Settings.BlockContact {
t.Fatalf("opposite owner full user = %+v, want unblocked owner-scoped state", aliceView.FullUser)
}
ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{
ok, err = r.onContactsUnblock(bobCtx, &tg.ContactsUnblockRequest{
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
})
if err != nil || !ok {
t.Fatalf("contacts.unblock = %v, %v", ok, err)
}
blocked, err = r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10})
blocked, err = r.onContactsGetBlocked(bobCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
if err != nil {
t.Fatalf("contacts.getBlocked after unblock: %v", err)
}
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 {
t.Fatalf("blocked after unblock = %T %+v, want empty contacts.blocked", blocked, blocked)
}
userFull, err = r.onUsersGetFullUser(bobCtx, &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash})
if err != nil {
t.Fatalf("users.getFullUser after unblock: %v", err)
}
if userFull.FullUser.Blocked || userFull.FullUser.BlockedMyStoriesFrom || !userFull.FullUser.Settings.BlockContact {
t.Fatalf("full user after unblock = %+v, want unblocked flags and block action", userFull.FullUser)
}
}
func TestContactsBlockGetBlockedAndUnblockAcrossExactProfiles(t *testing.T) {
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
alice, err := userStore.Create(ctx, domain.User{
AccessHash: 11,
Phone: "15550009101",
FirstName: "Alice",
})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := userStore.Create(ctx, domain.User{
AccessHash: 22,
Phone: "15550009102",
FirstName: "Bob",
})
if err != nil {
t.Fatalf("create bob: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Contacts: appcontacts.NewService(memory.NewContactStore(), userStore),
}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, bob.ID)
peer := &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsBlockRequest{ID: peer}); method != "contacts.block" {
t.Fatalf("block method = %q", method)
}
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsGetBlockedRequest{Limit: 10}); method != "contacts.getBlocked" {
t.Fatalf("getBlocked method = %q", method)
}
blocked, err := r.onContactsGetBlocked(ownerCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
if err != nil {
t.Fatalf("read blocked after exact block: %v", err)
}
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 1 {
t.Fatalf("blocked after exact block = %T %+v", blocked, blocked)
}
result, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.UsersGetFullUserRequest{
ID: &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash},
})
if method != "users.getFullUser" {
t.Fatalf("getFullUser method = %q", method)
}
var responseWire bin.Buffer
if err := result.Encode(&responseWire); err != nil {
t.Fatalf("encode exact blocked full user: %v", err)
}
decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: responseWire.Copy()}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode exact blocked full user: %v", err)
}
blockedFull, ok := decoded.(*tg.UsersUserFull)
if !ok || !blockedFull.FullUser.Blocked || !blockedFull.FullUser.BlockedMyStoriesFrom || blockedFull.FullUser.Settings.BlockContact {
t.Fatalf("Layer %d blocked full user = %T %+v", profile, decoded, decoded)
}
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsUnblockRequest{ID: peer}); method != "contacts.unblock" {
t.Fatalf("unblock method = %q", method)
}
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsGetBlockedRequest{Limit: 10}); method != "contacts.getBlocked" {
t.Fatalf("getBlocked after unblock method = %q", method)
}
blocked, err = r.onContactsGetBlocked(ownerCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
if err != nil {
t.Fatalf("read blocked after exact unblock: %v", err)
}
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 {
t.Fatalf("blocked after exact unblock = %T %+v", blocked, blocked)
}
result, method = dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.UsersGetFullUserRequest{
ID: &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash},
})
if method != "users.getFullUser" {
t.Fatalf("getFullUser after unblock method = %q", method)
}
responseWire.Reset()
if err := result.Encode(&responseWire); err != nil {
t.Fatalf("encode exact unblocked full user: %v", err)
}
decoded, err = tlprofile.DecodeObject(profile, &bin.Buffer{Buf: responseWire.Copy()}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode exact unblocked full user: %v", err)
}
unblockedFull, ok := decoded.(*tg.UsersUserFull)
if !ok || unblockedFull.FullUser.Blocked || unblockedFull.FullUser.BlockedMyStoriesFrom || !unblockedFull.FullUser.Settings.BlockContact {
t.Fatalf("Layer %d unblocked full user = %T %+v", profile, decoded, decoded)
}
})
}
}

View file

@ -7,6 +7,8 @@ import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
type ctxKey int
@ -96,6 +98,18 @@ func ClientInfoFrom(ctx context.Context) (ClientInfo, bool) {
return v, ok
}
func clientSessionMetadataFromContext(ctx context.Context) domain.ClientSessionMetadata {
metadata := domain.ClientSessionMetadata{}
metadata.AuthKeyID = rawAuthKeyIDForOrigin(ctx)
metadata.SessionID, _ = SessionIDFrom(ctx)
if info, ok := ClientInfoFrom(ctx); ok {
metadata.SystemLangCode = info.SystemLangCode
metadata.LangPack = info.LangPack
metadata.LangCode = info.LangCode
}
return metadata
}
func ClientTypeFrom(ctx context.Context) ClientType {
if info, ok := ClientInfoFrom(ctx); ok {
return info.ClientType()

View file

@ -183,7 +183,7 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
if rich := optionalTGRichMessage("channel_message", m.ID, m.RichMessage); rich != nil {
msg.SetRichMessage(*rich)
}
if replies := tgChannelMessageReplies(m.Replies); replies != nil {
@ -549,7 +549,13 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
CanViewParticipants: channelMemberIsAdmin(view.Self) || !ch.MembersListAdminOnly(),
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
// TDesktop only exposes the Statistics entry after channelFull.can_view_stats.
// The stats RPCs enforce the same creator/admin boundary, so project the
// capability from the membership instead of leaving a reachable service
// hidden behind a permanently false wire flag. Monoforum is an internal
// direct-message container and has no independent statistics surface.
CanViewStats: !ch.Monoforum && channelMemberIsAdmin(view.Self),
ID: ch.ID,
// Official clients render localized warnings from scam/fake flags.
// About remains the owner's unmodified description.
About: ch.About,
@ -648,6 +654,23 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
return full
}
// applyChannelStatsCapability completes the config-dependent half of the
// channelFull statistics capability. TDLib deliberately clears
// can_view_stats when stats_dc is absent or invalid, so these fields must be
// projected as one invariant rather than as independent optional hints.
// A manually constructed zero-value Router config is treated as unavailable;
// production config validation requires a positive canonical DC.
func (r *Router) applyChannelStatsCapability(full *tg.ChannelFull) {
if full == nil || !full.CanViewStats {
return
}
if r.cfg.DC <= 0 {
full.CanViewStats = false
return
}
full.SetStatsDC(r.cfg.DC)
}
func channelMemberIsAdmin(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
}
@ -848,23 +871,24 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage
func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
Other: true,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
Other: true,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
ManageWelcomeMessages: rights.ManageWelcomeMessages,
// manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum,
// 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。
ManageDirectMessages: rights.ManageDirectMessages,
@ -877,24 +901,25 @@ func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.Chann
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
return domain.ChannelAdminRights{
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
ManageChat: rights.Other,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
ManageDirectMessages: rights.ManageDirectMessages,
ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories,
EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall,
ManageChat: rights.Other,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
ManageWelcomeMessages: rights.ManageWelcomeMessages,
ManageDirectMessages: rights.ManageDirectMessages,
}
}

View file

@ -68,6 +68,46 @@ func TestTGChannelFullIncludesExportedInvite(t *testing.T) {
}
}
func TestChannelFullStatsCapabilityRequiresEligibleViewerAndExactDC(t *testing.T) {
tests := []struct {
name string
dc int
monoforum bool
role domain.ChannelMemberRole
want bool
}{
{name: "creator", dc: 2, role: domain.ChannelRoleCreator, want: true},
{name: "ordinary member", dc: 2, role: domain.ChannelRoleMember},
{name: "monoforum creator", dc: 2, monoforum: true, role: domain.ChannelRoleCreator},
{name: "invalid canonical dc", role: domain.ChannelRoleCreator},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
view := domain.ChannelView{
Channel: domain.Channel{ID: 1003, Monoforum: tc.monoforum},
Self: domain.ChannelMember{
ChannelID: 1003,
UserID: 10,
Status: domain.ChannelMemberActive,
Role: tc.role,
},
}
full := tgChannelFull(view)
(&Router{cfg: Config{DC: tc.dc}}).applyChannelStatsCapability(full)
statsDC, ok := full.GetStatsDC()
if tc.want {
if !full.CanViewStats || !ok || statsDC != tc.dc {
t.Fatalf("can_view_stats=%v stats_dc=(%d,%v), want true and (%d,true)", full.CanViewStats, statsDC, ok, tc.dc)
}
return
}
if full.CanViewStats || ok {
t.Fatalf("can_view_stats=%v stats_dc=(%d,%v), want false and absent", full.CanViewStats, statsDC, ok)
}
})
}
}
func TestChannelBannedRightsRoundTripModernFields(t *testing.T) {
in := tg.ChatBannedRights{
ViewMessages: true,

View file

@ -202,7 +202,7 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
Date: d.Date,
Effect: d.Effect,
}
if rich := mustTGRichMessage(d.RichMessage); rich != nil {
if rich := optionalTGRichMessage("dialog_draft", 0, d.RichMessage); rich != nil {
out.SetRichMessage(*rich)
}
if suggested, ok := tgSuggestedPost(d.SuggestedPost); ok {

View file

@ -229,21 +229,21 @@ func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot
}
}
func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(button)
func domainReplyKeyboardButton(button tg.KeyboardButton) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(&button)
if err != nil {
return domain.MarkupButton{}, err
}
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon}
switch b := button.(type) {
case *tg.KeyboardButton:
base.Type, base.Text = domain.MarkupButtonText, b.Text
case *tg.KeyboardButtonRequestPhone:
base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text
case *tg.KeyboardButtonRequestGeoLocation:
base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text
case *tg.KeyboardButtonRequestPoll:
base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text
base := domain.MarkupButton{Text: button.Text, Style: style, IconCustomEmojiID: icon}
switch b := button.Type.(type) {
case *tg.ButtonTypeDefault:
base.Type = domain.MarkupButtonText
case *tg.ButtonTypeRequestPhone:
base.Type = domain.MarkupButtonRequestPhone
case *tg.ButtonTypeRequestGeoLocation:
base.Type = domain.MarkupButtonRequestLocation
case *tg.ButtonTypeRequestPoll:
base.Type = domain.MarkupButtonRequestPoll
if quiz, ok := b.GetQuiz(); ok {
if quiz {
base.PollType = "quiz"
@ -251,12 +251,12 @@ func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButt
base.PollType = "regular"
}
}
case *tg.KeyboardButtonRequestPeer:
base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text
case *tg.ButtonTypeRequestPeer:
base.Type = domain.MarkupButtonRequestPeer
base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType)
case *tg.KeyboardButtonSimpleWebView:
base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL
case *tg.ButtonTypeSimpleWebView:
base.Type, base.URL = domain.MarkupButtonSimpleWebView, b.URL
default:
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
@ -281,27 +281,27 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku
return out, nil
}
func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(btn)
func domainMarkupButton(btn tg.KeyboardInlineButton, buttonID int) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(&btn)
if err != nil {
return domain.MarkupButton{}, err
}
switch b := btn.(type) {
case *tg.KeyboardButtonCallback:
switch b := btn.Type.(type) {
case *tg.InlineButtonTypeCallback:
return domain.MarkupButton{
Type: domain.MarkupButtonCallback,
Text: b.Text,
Text: btn.Text,
Style: style,
IconCustomEmojiID: icon,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
}, nil
case *tg.KeyboardButtonURL:
case *tg.InlineButtonTypeURL:
return domain.MarkupButton{
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
Type: domain.MarkupButtonURL, Text: btn.Text, URL: b.URL,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.InputKeyboardButtonURLAuth:
case *tg.InputInlineButtonTypeURLAuth:
botUserID := int64(0)
switch bot := b.Bot.(type) {
case nil, *tg.InputUserEmpty, *tg.InputUserSelf:
@ -311,33 +311,35 @@ func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.Markup
return domain.MarkupButton{}, domain.ErrButtonInvalid
}
return domain.MarkupButton{
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
Type: domain.MarkupButtonLoginURL, Text: btn.Text, URL: b.URL,
ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonURLAuth:
case *tg.InlineButtonTypeURLAuth:
return domain.MarkupButton{
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
Type: domain.MarkupButtonLoginURL, Text: btn.Text, URL: b.URL,
ForwardText: b.FwdText, ButtonID: b.ButtonID,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonWebView:
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonSwitchInline:
case *tg.InlineButtonTypeWebView:
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: btn.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
case *tg.InlineButtonTypeSwitchInline:
peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes)
if err != nil {
return domain.MarkupButton{}, domain.ErrButtonInvalid
}
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonCopy:
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: btn.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
case *tg.InlineButtonTypeCopy:
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: btn.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
default:
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
}
func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) {
func domainMarkupButtonStyle(btn interface {
GetStyle() (tg.KeyboardButtonStyle, bool)
}) (domain.MarkupButtonStyle, int64, error) {
style, ok := btn.GetStyle()
if !ok {
return "", 0, nil
@ -388,7 +390,7 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
case domain.MessageReplyMarkupKeyboard:
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
for _, row := range m.Keyboard {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
buttons := make([]tg.KeyboardButton, 0, len(row))
for _, btn := range row {
buttons = append(buttons, tgReplyKeyboardButton(btn))
}
@ -415,93 +417,77 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
default:
return nil
}
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
rows := make([]tg.KeyboardInlineButtonRow, 0, len(m.Inline))
for _, row := range m.Inline {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
buttons := make([]tg.KeyboardInlineButton, 0, len(row))
for _, btn := range row {
buttons = append(buttons, tgMarkupButton(btn))
}
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
rows = append(rows, tg.KeyboardInlineButtonRow{Buttons: buttons})
}
return &tg.ReplyInlineMarkup{Rows: rows}
}
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardInlineButton {
out := tg.KeyboardInlineButton{Text: btn.Text}
switch btn.Type {
case domain.MarkupButtonURL:
out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
out.Type = &tg.InlineButtonTypeURL{URL: btn.URL}
case domain.MarkupButtonLoginURL:
out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID}
buttonType := &tg.InlineButtonTypeURLAuth{URL: btn.URL, ButtonID: btn.ButtonID}
if btn.ForwardText != "" {
out.SetFwdText(btn.ForwardText)
buttonType.SetFwdText(btn.ForwardText)
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
out.Type = buttonType
case domain.MarkupButtonWebView:
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
out.Type = &tg.InlineButtonTypeWebView{URL: btn.URL}
case domain.MarkupButtonSwitchInline:
out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer}
buttonType := &tg.InlineButtonTypeSwitchInline{Query: btn.Query, SamePeer: btn.SamePeer}
if len(btn.PeerTypes) > 0 {
out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
buttonType.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
out.Type = buttonType
case domain.MarkupButtonCopy:
out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
out.Type = &tg.InlineButtonTypeCopy{CopyText: btn.CopyText}
case domain.MarkupButtonBuy:
out.Type = &tg.InlineButtonTypeBuy{}
default: // callback
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
buttonType := &tg.InlineButtonTypeCallback{Data: btn.Data}
if btn.RequiresPassword {
out.SetRequiresPassword(true)
buttonType.SetRequiresPassword(true)
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
out.Type = buttonType
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
}
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
var out tg.KeyboardButtonClass
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButton {
out := tg.KeyboardButton{Text: btn.Text}
switch btn.Type {
case domain.MarkupButtonRequestPhone:
out = &tg.KeyboardButtonRequestPhone{Text: btn.Text}
out.Type = &tg.ButtonTypeRequestPhone{}
case domain.MarkupButtonRequestLocation:
out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text}
out.Type = &tg.ButtonTypeRequestGeoLocation{}
case domain.MarkupButtonRequestPoll:
button := &tg.KeyboardButtonRequestPoll{Text: btn.Text}
button := &tg.ButtonTypeRequestPoll{}
if btn.PollType == "quiz" {
button.SetQuiz(true)
} else if btn.PollType == "regular" {
button.SetQuiz(false)
}
out = button
out.Type = button
case domain.MarkupButtonRequestPeer:
out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
out.Type = &tg.ButtonTypeRequestPeer{ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
case domain.MarkupButtonSimpleWebView:
out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL}
out.Type = &tg.ButtonTypeSimpleWebView{URL: btn.URL}
default:
out = &tg.KeyboardButton{Text: btn.Text}
out.Type = &tg.ButtonTypeDefault{}
}
if style, ok := tgMarkupButtonStyle(btn); ok {
if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok {
setter.SetStyle(style)
}
out.SetStyle(style)
}
return out
}

View file

@ -15,10 +15,10 @@ func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
Selective: true,
Persistent: true,
Placeholder: "Choose",
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButton{Text: "Help"},
func() *tg.KeyboardButton {
button := &tg.KeyboardButton{Text: "Status"}
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButton{
{Text: "Help", Type: &tg.ButtonTypeDefault{}},
func() tg.KeyboardButton {
button := tg.KeyboardButton{Text: "Status", Type: &tg.ButtonTypeDefault{}}
style := tg.KeyboardButtonStyle{}
style.SetBgPrimary(true)
style.SetIcon(123456)
@ -43,7 +43,8 @@ func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 {
t.Fatalf("wire markup = %#v", wire)
}
if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" {
button := &wire.Rows[0].Buttons[1]
if button.Text != "Status" {
t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1])
} else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 {
t.Fatalf("second button style = %#v ok=%v", style, ok)
@ -54,30 +55,31 @@ func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
}
func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")}
button := tg.KeyboardInlineButton{Text: "Delete", Type: &tg.InlineButtonTypeCallback{Data: []byte("delete")}}
style := tg.KeyboardButtonStyle{}
style.SetBgDanger(true)
button.SetStyle(style)
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{Buttons: []tg.KeyboardInlineButton{button}}}}, true)
if err != nil {
t.Fatalf("domainReplyMarkupForSender: %v", err)
}
if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger {
t.Fatalf("domain style = %#v", got.Inline[0][0])
}
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0]
if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() {
t.Fatalf("wire style = %#v ok=%v", roundTrip, ok)
}
}
func TestLoginURLButtonTLDomainProjection(t *testing.T) {
button := &tg.InputKeyboardButtonURLAuth{
Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
buttonType := &tg.InputInlineButtonTypeURLAuth{
URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
}
button.SetRequestWriteAccess(true)
button.SetFwdText("Open login")
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
buttonType.SetRequestWriteAccess(true)
buttonType.SetFwdText("Open login")
button := tg.KeyboardInlineButton{Text: "Log in", Type: buttonType}
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{Buttons: []tg.KeyboardInlineButton{button}}}}, true)
if err != nil {
t.Fatal(err)
}
@ -85,8 +87,9 @@ func TestLoginURLButtonTLDomainProjection(t *testing.T) {
if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 {
t.Fatalf("domain login_url = %#v", got)
}
wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth)
if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" {
wire := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0]
wireType, ok := wire.Type.(*tg.InlineButtonTypeURLAuth)
if !ok || wire.Text != "Log in" || wireType.URL != "https://example.com/login" || wireType.ButtonID != 0 || wireType.FwdText != "Open login" {
t.Fatalf("wire login_url = %#v", wire)
}
}
@ -112,7 +115,7 @@ func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}},
Buttons: []tg.KeyboardButton{{Text: "Share phone", Type: &tg.ButtonTypeRequestPhone{}}},
}}}, true)
if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 ||
markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone {
@ -122,7 +125,7 @@ func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 {
t.Fatalf("request_phone wire = %#v", wire)
}
if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok {
if _, ok := wire.Rows[0].Buttons[0].Type.(*tg.ButtonTypeRequestPhone); !ok {
t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0])
}
if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil {
@ -138,9 +141,9 @@ func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
chatType.SetHasUsername(false)
chatType.SetForum(true)
chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true})
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2},
&tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1},
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButton{
{Text: "Premium person", Type: &tg.ButtonTypeRequestPeer{ButtonID: 1, PeerType: userType, MaxQuantity: 2}},
{Text: "Forum", Type: &tg.ButtonTypeRequestPeer{ButtonID: 2, PeerType: chatType, MaxQuantity: 1}},
}}}}
markup, err := domainOutgoingReplyMarkupForSender(in, true)
if err != nil {
@ -157,14 +160,14 @@ func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
t.Fatalf("chat filter = %#v", chatFilter)
}
wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
wireUser := wire.Rows[0].Buttons[0].Type.(*tg.ButtonTypeRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
if bot, ok := wireUser.GetBot(); !ok || bot {
t.Fatalf("wire user bot=%v ok=%v", bot, ok)
}
if premium, ok := wireUser.GetPremium(); !ok || !premium {
t.Fatalf("wire user premium=%v ok=%v", premium, ok)
}
wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
wireChat := wire.Rows[0].Buttons[1].Type.(*tg.ButtonTypeRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
if !wireChat.Creator || !wireChat.BotParticipant {
t.Fatalf("wire chat = %#v", wireChat)
}
@ -177,10 +180,10 @@ func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
}
func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) {
button := &tg.InputKeyboardButtonRequestPeer{
button := tg.KeyboardButton{Text: "Share", Type: &tg.InputButtonTypeRequestPeer{
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
}
ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
}}
got, err := domainRequestedButtonFromTG(1001, nil, button)
if err != nil {
t.Fatal(err)

View file

@ -301,7 +301,7 @@ func tgDocument(d domain.Document) tg.DocumentClass {
Date: d.Date,
MimeType: d.MimeType,
Size: d.Size,
Thumbs: tgDocumentThumbs(d.MimeType, d.Thumbs),
Thumbs: tgDocumentThumbs(d.Thumbs),
DCID: d.DCID,
Attributes: tgDocumentAttributes(d.MimeType, d.Attributes),
}
@ -315,15 +315,12 @@ func tgDocuments(docs []domain.Document) []tg.DocumentClass {
return out
}
func tgDocumentThumbs(mimeType string, sizes []domain.PhotoSize) []tg.PhotoSizeClass {
func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil
}
out := make([]tg.PhotoSizeClass, 0, len(sizes))
for _, s := range sizes {
if isSeedSyntheticTGStickerPreviewThumb(mimeType, s) {
continue
}
if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 {
size := s.Size
if size == 0 {
@ -339,17 +336,6 @@ func tgDocumentThumbs(mimeType string, sizes []domain.PhotoSize) []tg.PhotoSizeC
return compactPhotoSizeClasses(out)
}
func isSeedSyntheticTGStickerPreviewThumb(mimeType string, s domain.PhotoSize) bool {
// Older seed imports gave TGS documents without thumbnails a 1x1 transparent
// "m" PNG. Clients can prefer that unusable preview and render blank stickers.
return mimeType == mimeApplicationXTGSticker &&
s.Kind == domain.PhotoSizeKindCached &&
s.Type == "m" &&
s.W <= 1 &&
s.H <= 1 &&
len(s.Bytes) > 0
}
func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil

View file

@ -119,7 +119,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
if rich := optionalTGRichMessage("private_message", m.ID, m.RichMessage); rich != nil {
msg.SetRichMessage(*rich)
}
if m.TTLPeriod > 0 {

View file

@ -2,52 +2,39 @@ package rpc
import (
"context"
"fmt"
"log"
"strconv"
"sync/atomic"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
// 本文件集中 Layer 228 富文本消息richMessage的 tg.* ↔ domain 转换。
// 本文件集中富文本消息richMessage的 tg.* ↔ domain 转换。
// inputRichMessage 的 blocks、HTML 与 Markdown 三种输入均在 RPC 边界归一为 PageBlock
// blocks 以 TL 向量序列化为不透明字节存 domain详见 domain.MessageRichMessage
// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)
func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) {
// encodeRichBlocks 把 []tg.PageBlockClass 按明确的存储 profile 序列化为 TL 向量字节。
func encodeRichBlocks(profile tlprofile.Profile, blocks []tg.PageBlockClass) ([]byte, error) {
var b bin.Buffer
b.PutVectorHeader(len(blocks))
for _, blk := range blocks {
if blk == nil {
return nil, mediaInvalidErr()
}
if err := blk.Encode(&b); err != nil {
return nil, err
}
if err := tlprofile.EncodePageBlockVector(profile, blocks, &b); err != nil {
return nil, err
}
return b.Buf, nil
}
// decodeRichBlocks 把 encodeRichBlocks 产生的字节还原为 []tg.PageBlockClass。
func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
// decodeRichBlocks 按写入时的 exact profile 还原完整 PageBlock 向量。调用方必须提供
// 持久化元数据确定的 profile不允许失败后换 profile 重试。
func decodeRichBlocks(profile tlprofile.Profile, data []byte) ([]tg.PageBlockClass, error) {
if len(data) == 0 {
return nil, nil
}
b := &bin.Buffer{Buf: append([]byte(nil), data...)}
n, err := b.VectorHeader()
if err != nil {
return nil, err
}
out := make([]tg.PageBlockClass, 0, n)
for i := 0; i < n; i++ {
blk, err := tg.DecodePageBlock(b)
if err != nil {
return nil, err
}
out = append(out, blk)
}
return out, nil
return tlprofile.DecodePageBlockVector(profile, b, tlprofile.Limits{})
}
// richMessageMediaRefs is the media closure referenced by one PageBlock graph.
@ -376,13 +363,14 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
return nil, notImplementedErr()
}
normalizeRichBlocksForClients(in.Blocks)
blocks, err := encodeRichBlocks(in.Blocks)
blocks, err := encodeRichBlocks(tlprofile.ProfileCanonical, in.Blocks)
if err != nil {
return nil, err
}
rich := &domain.MessageRichMessage{
Rtl: in.Rtl,
Blocks: blocks,
Rtl: in.Rtl,
BlocksLayer: int(tlprofile.ProfileCanonical),
Blocks: blocks,
}
projection, projectionErr := botAPIRichMessageProjection(in.Blocks, in.Rtl)
if projectionErr != nil && sourceParsed {
@ -411,9 +399,14 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
if m.IsZero() {
return nil, nil
}
blocks, err := decodeRichBlocks(m.Blocks)
layer := m.EffectiveBlocksLayer()
profile, ok := tlprofile.ResolveProfile(layer)
if !ok {
return nil, fmt.Errorf("stored rich_message blocks layer %d is unavailable", layer)
}
blocks, err := decodeRichBlocks(profile, m.Blocks)
if err != nil {
return nil, err
return nil, fmt.Errorf("decode stored rich_message blocks at layer %d: %w", layer, err)
}
out := &tg.RichMessage{
Rtl: m.Rtl,
@ -431,10 +424,22 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
return out, nil
}
func mustTGRichMessage(m *domain.MessageRichMessage) *tg.RichMessage {
var richMessageProjectionFailureCount atomic.Uint64
// optionalTGRichMessage projects an optional extension without allowing one
// malformed persisted snapshot to terminate the RPC worker or server process.
// Known historical formats are decoded exactly above. Truly invalid data is
// omitted from the base message and compatibility-traced with logarithmic
// sampling so a repeatedly requested row cannot create an unbounded log storm.
func optionalTGRichMessage(scope string, id int, m *domain.MessageRichMessage) *tg.RichMessage {
out, err := tgRichMessage(m)
if err != nil {
panic("invalid stored rich_message: " + err.Error())
count := richMessageProjectionFailureCount.Add(1)
if count <= 10 || count&(count-1) == 0 {
log.Printf("rich_message compatibility trace: scope=%s id=%d blocks_layer=%d failures=%d error=%q",
scope, id, m.EffectiveBlocksLayer(), count, err)
}
return nil
}
return out
}

View file

@ -23,9 +23,11 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
addChannels(out, seenChats, event.UserID, event.Channels)
switch event.Type {
case domain.UpdateEventNewMessage:
if msg := tgMessage(event.Message); msg != nil {
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
if messageSnapshotVisible(event.Message) {
if msg := tgMessage(event.Message); msg != nil {
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
}
}
case domain.UpdateEventReadHistoryInbox:
if update := tgReadHistoryInboxUpdate(event); update != nil {
@ -38,9 +40,11 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
case domain.UpdateEventMessagePoll:
// 同时下发消息快照(含最新聚合)与对应通知 update事件无 TL pts
// pts 推进靠 difference state 本身。
if msg := tgMessage(event.Message); msg != nil {
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
if messageSnapshotVisible(event.Message) {
if msg := tgMessage(event.Message); msg != nil {
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
}
}
if update := tgOtherUpdateFromEvent(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)
@ -90,6 +94,10 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
return out
}
func messageSnapshotVisible(msg domain.Message) bool {
return !msg.Deleted
}
func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.UpdatesChannelDifferenceClass {
if diff.TooLong {
messages := make([]tg.MessageClass, 0, len(diff.NewMessages))

View file

@ -9,6 +9,7 @@ import (
// tgSelfUser 把 domain.User 转为 self 标记的 tg.Useroptional 字段由 Encode 自动 SetFlags
func tgSelfUser(u domain.User) *tg.User {
u = officialSystemUserPresentation(u)
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
@ -42,6 +43,7 @@ func tgSelfUser(u domain.User) *tg.User {
}
func tgUser(u domain.User) *tg.User {
u = officialSystemUserPresentation(u)
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
@ -73,6 +75,22 @@ func tgUser(u domain.User) *tg.User {
return out
}
// officialSystemUserPresentation keeps the durable reserved identity stable
// while projecting the deployment brand from configuration. Older databases
// may still contain "Telesrv" in users.first_name; without this boundary
// normalization clients alternate between the database row and the synthetic
// system-user snapshot as caches are refreshed.
func officialSystemUserPresentation(u domain.User) domain.User {
if u.ID != domain.OfficialSystemUserID {
return u
}
official := domain.OfficialSystemUser()
u.FirstName = official.FirstName
u.LastName = official.LastName
u.Username = official.Username
return u
}
func applyTgUserRestrictionFields(out *tg.User, u domain.User) {
if out == nil || len(u.RestrictionReasons) == 0 {
return

View file

@ -2,12 +2,18 @@ package rpc
import (
"context"
"reflect"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appstories "telesrv/internal/app/stories"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
@ -54,3 +60,108 @@ func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
t.Fatalf("history TL user = %+v", got)
}
}
func TestDeletedUserReadModelOverlaysRemainMinimal(t *testing.T) {
ctx := context.Background()
viewerID := int64(7)
deletedID := int64(42)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: deletedID}
now := int(time.Now().Unix())
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: peer, ID: 1, Date: now, ExpireDate: now + 3600, Public: true,
}}); err != nil {
t.Fatalf("upsert retained story: %v", err)
}
stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)}
usernames := newFakeUsernameRegistry()
usernames.byPeer[peer] = []domain.Username{{Username: "retained_collectible", Active: true, CollectibleID: 9}}
verifications := newFakeBotVerifications()
verifications.marks[peer] = domain.CustomVerification{
VerifierBotID: 9001, Peer: peer, IconDocumentID: 9002, Description: "retained mark",
}
r := New(Config{}, Deps{
Stories: stories, Usernames: usernames, BotVerifications: verifications,
}, zaptest.NewLogger(t), clock.System)
users := []tg.UserClass{tgUser(domain.User{ID: deletedID, Deleted: true, DeletedAt: int64(now)})}
r.applyPeerReadModels(ctx, viewerID, users, nil)
u := users[0].(*tg.User)
_, usernamesSet := u.GetUsernames()
_, storiesSet := u.GetStoriesMaxID()
_, verificationSet := u.GetBotVerificationIcon()
if !u.Deleted || u.ID != deletedID || usernamesSet || storiesSet || u.GetStoriesHidden() || verificationSet {
t.Fatalf("deleted user gained retained read-model overlays: %+v", u)
}
if stories.projectionCalls != 0 || usernames.peerCalls != 0 || usernames.batchCalls != 0 || verifications.peerCalls != 0 || verifications.batchCalls != 0 {
t.Fatalf("deleted user triggered overlay reads: stories=%d usernames=(%d,%d) verifications=(%d,%d)",
stories.projectionCalls, usernames.peerCalls, usernames.batchCalls, verifications.peerCalls, verifications.batchCalls)
}
}
func TestGetFullDeletedUserReturnsOnlyTombstone(t *testing.T) {
ctx := context.Background()
viewer := domain.User{ID: 7, FirstName: "Viewer"}
deleted := domain.User{
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
Username: "released", About: "retained about", PhotoID: 123, Deleted: true,
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID}
now := int(time.Now().Unix())
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: peer, ID: 1, Date: now, ExpireDate: now + 3600, Public: true,
}}); err != nil {
t.Fatalf("upsert retained story: %v", err)
}
stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)}
usernames := newFakeUsernameRegistry()
usernames.byPeer[peer] = []domain.Username{{Username: "retained_collectible", Active: true, CollectibleID: 9}}
verifications := newFakeBotVerifications()
verifications.marks[peer] = domain.CustomVerification{
VerifierBotID: 9001, Peer: peer, IconDocumentID: 9002, Description: "retained mark",
}
r := New(Config{}, Deps{
Users: mapUsersService{users: map[int64]domain.User{
viewer.ID: viewer,
deleted.ID: deleted,
}},
Stories: stories, Usernames: usernames, BotVerifications: verifications,
}, zaptest.NewLogger(t), clock.System)
got, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{
UserID: deleted.ID, AccessHash: deleted.AccessHash,
})
if err != nil {
t.Fatalf("get full deleted user: %v", err)
}
wantFull := tg.UserFull{
ID: deleted.ID,
Settings: tg.PeerSettings{},
NotifySettings: *tdesktop.NotifySettings(),
}
if !reflect.DeepEqual(got.FullUser, wantFull) {
t.Fatalf("full deleted user = %+v, want minimal %+v", got.FullUser, wantFull)
}
if len(got.Users) != 1 {
t.Fatalf("users = %+v, want one tombstone", got.Users)
}
u, ok := got.Users[0].(*tg.User)
if !ok || !reflect.DeepEqual(u, &tg.User{ID: deleted.ID, Deleted: true}) {
t.Fatalf("deleted user envelope = %+v", got.Users[0])
}
if len(got.Chats) != 0 {
t.Fatalf("deleted user chats = %+v, want none", got.Chats)
}
if stories.projectionCalls != 0 || usernames.peerCalls != 0 || usernames.batchCalls != 0 || verifications.peerCalls != 0 || verifications.batchCalls != 0 {
t.Fatalf("deleted getFullUser triggered retained read-model reads: stories=%d usernames=(%d,%d) verifications=(%d,%d)",
stories.projectionCalls, usernames.peerCalls, usernames.batchCalls, verifications.peerCalls, verifications.batchCalls)
}
wire := &tg.UsersUserFull{}
tlRoundTrip(t, got, wire)
if !reflect.DeepEqual(wire.FullUser, wantFull) || len(wire.Users) != 1 {
t.Fatalf("wire deleted user full = %+v", wire)
}
}

View file

@ -6,11 +6,13 @@ import (
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
"telesrv/internal/sfu"
"telesrv/internal/store"
"telesrv/internal/turnsrv"
"telesrv/internal/updatecdn"
)
// 本文件按「消费者定义接口」惯例,在 rpc 包定义 Router 依赖的业务服务接口。
@ -19,11 +21,11 @@ import (
// AuthService 抽象登录/注册业务。
type AuthService interface {
BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error
BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (domain.TempAuthKeyBindingResult, error)
ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error)
UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error)
PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error)
CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error
CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte, expectedUserID int64) error
SendCode(ctx context.Context, phone string) (string, error)
CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error)
ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error)
@ -99,6 +101,29 @@ type SessionUpdatesStateProvider interface {
ReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64) bool
}
// SessionUpdatesActivationProvider serializes the expensive transition from an
// authenticated physical session to updates-ready. A successful claim belongs
// to exactly one physical connection generation; the token prevents a delayed
// callback from an old connection clearing a replacement's claim.
//
// This capability gates only membership synchronization and readiness. Each
// updates.getState/getDifference delivery keeps its own cursor commit and must
// never be coalesced with another RPC.
type SessionUpdatesActivationProvider interface {
BeginSessionUpdatesActivation(rawAuthKeyID [8]byte, sessionID int64) (token uint64, ok bool)
EndSessionUpdatesActivation(rawAuthKeyID [8]byte, sessionID int64, token uint64)
}
// SessionBootstrapProbeProvider makes the durable bootstrap-job readiness
// lookup a one-shot per physical connection generation. A successful probe
// includes an authoritative zero-row result. Failed delivery work releases the
// claim so a later delivered baseline can retry; replacement connections own
// independent state and reject completion from an older generation.
type SessionBootstrapProbeProvider interface {
BeginSessionBootstrapProbe(rawAuthKeyID [8]byte, sessionID int64) (token uint64, ok bool)
EndSessionBootstrapProbe(rawAuthKeyID [8]byte, sessionID int64, token uint64, success bool)
}
// ClientLayerBinder 把协商 TL layer 即时下推到连接(可选能力)。
// invokeWithLayer 在 Dispatch 入口被观测到时立即调用,使同一请求 handler 执行期间
// 触发的 pending flush / 并发 push 就已按正确 layer 降级;不实现时连接层只能靠
@ -193,20 +218,20 @@ type TransientSessionBinder interface {
}
// AuthKeyTargetedSessionBinder 把 update 定向投递给某用户【绑定到具体 business auth_key
// 这台设备】的就绪连接密聊设备级投递。SessionManager 实现;测试替身/未装配时
// rpc 层回退账号级推送。未就绪连接跳过、不进 pending密聊离线靠 getDifference 补)。
// 这台设备】的就绪连接密聊设备级投递。SessionManager 实现;密聊启用时必须装配,
// 缺失时 fail-closed严禁回退账号级推送。未就绪连接跳过、不进 pending离线靠 difference 补)。
type AuthKeyTargetedSessionBinder interface {
PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass) (int, error)
PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
PushToUserExceptBusinessAuthKey(ctx context.Context, userID int64, excludeBusinessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
}
// ExactLayerTransientSessionBinder is the admission boundary for updates whose
// constructors do not exist in older profiles. Implementations must filter the
// live session index before encoding, skip unknown/not-ready profiles, and must
// never queue the transient payload for later delivery.
type ExactLayerTransientSessionBinder interface {
PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
// SemanticTransientSessionBinder filters by generated exact-profile metadata
// instead of a hard-coded minimum layer. A newly generated profile therefore
// becomes eligible automatically when it has a wire constructor for semantic.
type SemanticTransientSessionBinder interface {
PushToUserTransientCompatible(ctx context.Context, userID int64, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
PushToUserAuthKeyTransientCompatible(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error)
}
// OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout.
@ -271,6 +296,17 @@ type UsersService interface {
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
}
// BaseUserBotStatusProvider exposes the immutable, viewer-independent bot bit
// without constructing a full user projection. Production users.Service
// implements it through the shared base-user Redis read model.
type BaseUserBotStatusProvider interface {
BotStatus(ctx context.Context, userID int64) (bot bool, found bool, err error)
}
type UserProjectionFactInvalidator interface {
InvalidateAccountFreezeFact(userID int64)
}
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
// consent transitions; the RPC layer only projects domain state to TL.
@ -291,12 +327,20 @@ type TelegramLoginService interface {
// BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user
// fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer
// 与 ByIDs(viewer, ids) 字节等价personal photo overlay 除外,见 users.ByIDsForViewers
// 未实现时 fan-out 预热静默跳过,回退逐 viewer 解析(行为不变,仅退化为旧的 O(viewer) 成本)。
// 与 ByIDs(viewer, ids) 字节等价,包含 viewer-specific personal photo overlay。
// 声明需要 fan-out 预热的路径必须具备该能力;缺失或失败时在线 fan-out fail-closed
// 不得在同一请求里改走逐 recipient 查询。
type BatchViewerUsersResolver interface {
ByIDsForViewers(ctx context.Context, viewerUserIDs []int64, userIDs []int64) (map[int64][]domain.User, error)
}
// SparseBatchViewerUsersResolver projects only the explicitly supplied
// viewer->user edges. Local Durable Outbox uses this instead of widening one
// claim into viewers x union(users).
type SparseBatchViewerUsersResolver interface {
ByIDsForViewerUserIDs(ctx context.Context, userIDsByViewer map[int64][]int64) (map[int64][]domain.User, error)
}
// BotsService 抽象 bot 元数据查询与管理bots.* RPC + userFull.bot_info hydrate
// 写方法返回 bump 后的 bot_info_version客户端据此重拉
type BotsService interface {
@ -429,6 +473,7 @@ type AccountService interface {
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
CheckPassword(ctx context.Context, userID int64, check domain.PasswordCheck) error
RevenueWithdrawalPasswordState(ctx context.Context, userID int64) (domain.RevenueWithdrawalPasswordState, error)
RequestPasswordRecovery(ctx context.Context, userID int64) (string, error)
CheckRecoveryPassword(ctx context.Context, userID int64, code string) error
RecoverPassword(ctx context.Context, userID int64, code string, input *domain.PasswordInputSettings) error
@ -505,6 +550,15 @@ type AccountFreezeService interface {
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
}
// AccountFreezeNotificationService owns the durable non-PTS notification
// queue. It is intentionally separate from AccountFreezeService so hot
// read-only gates can use a versioned fact cache without disabling queue
// consumption.
type AccountFreezeNotificationService interface {
ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error)
CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error
}
// UpdatesService 抽象 update 状态查询。
type UpdatesService interface {
GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error)
@ -808,6 +862,9 @@ type ChannelsService interface {
GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error)
SearchChannelMedia(ctx context.Context, userID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error)
CountChannelMediaCategories(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error)
GetStats(ctx context.Context, userID int64, req domain.ChannelStatsRequest) (domain.ChannelStats, error)
GetMessageStats(ctx context.Context, userID int64, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error)
ListMessagePublicForwards(ctx context.Context, userID int64, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error)
SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error)
SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error)
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
@ -973,6 +1030,18 @@ type EphemeralService interface {
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
}
// WelcomeMessageService owns the independent durable Layer 229 peer templates.
// It has no transient device, PTS, difference, push or outbox responsibility.
type WelcomeMessageService interface {
Authorize(ctx context.Context, userID int64, peer domain.Peer) error
Create(ctx context.Context, userID int64, peer domain.Peer, randomID int64, content domain.WelcomeMessageContent) (domain.WelcomeMessage, bool, error)
Edit(ctx context.Context, userID int64, peer domain.Peer, id int, fields domain.WelcomeMessageEditFields) (domain.WelcomeMessage, error)
List(ctx context.Context, userID int64, peer domain.Peer, hash int64) (domain.WelcomeMessageList, error)
Delete(ctx context.Context, userID int64, peer domain.Peer, id int) (bool, error)
DeleteAll(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
HasAny(ctx context.Context, peer domain.Peer) (bool, error)
}
// ModerationService accepts only final report choices. Implementations must
// validate and snapshot referenced evidence, then durably commit the immutable
// submission before returning success.
@ -1066,50 +1135,55 @@ type Deps struct {
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
// explicit invokeWithLayer evidence. Production must wire the same auth-key
// store used by the MTProto edge; nil is reserved for isolated router tests.
AuthKeySessionLayers store.AuthKeySessionLayerStore
Account AccountService
Privacy PrivacyService
Help HelpService
AccountFreeze AccountFreezeService
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService
Messages MessagesService
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Communities CommunitiesService
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
ServiceBotInlineResults ServiceBotInlineResults
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService
LiveStreams LiveStreamsService
SFU sfu.Service
TURN turnsrv.Service
LangPack LangPackService
Sessions SessionBinder
Inline store.InlineRegistryStore
Limiter RateLimiter
Metrics Metrics
SecretChats SecretChatService
Passkey PasskeyService
Themes ThemeService
AuthKeySessionLayers store.AuthKeySessionLayerStore
ReadModelVersions store.ReadModelVersionStore
UserProjectionFacts UserProjectionFactInvalidator
Account AccountService
Privacy PrivacyService
Help HelpService
AppUpdates updatecdn.Resolver
AccountFreeze AccountFreezeService
AccountFreezeNotifications AccountFreezeNotificationService
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
WelcomeMessages WelcomeMessageService
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService
Messages MessagesService
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Communities CommunitiesService
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
ServiceBotInlineResults ServiceBotInlineResults
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService
LiveStreams LiveStreamsService
SFU sfu.Service
TURN turnsrv.Service
LangPack LangPackService
Sessions SessionBinder
Inline store.InlineRegistryStore
Limiter RateLimiter
Metrics Metrics
SecretChats SecretChatService
Passkey PasskeyService
Themes ThemeService
}
// ThemeService 抽象自定义云主题(app/themes):创建/更新/查询主题 + 维护每用户已安装列表。
@ -1143,12 +1217,12 @@ type PasskeyService interface {
type SecretChatService interface {
RequestEncryption(ctx context.Context, req domain.SecretChatRequest) (domain.SecretChat, error)
AcceptEncryption(ctx context.Context, chatID int, viewerUserID, participantAuthKeyID, accessHash int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error)
DiscardEncryption(ctx context.Context, chatID int, viewerUserID int64, deleteHistory bool) (domain.SecretChat, bool, error)
DiscardEncryption(ctx context.Context, chatID int, viewerUserID, viewerAuthKeyID int64, deleteHistory bool) (domain.SecretChat, bool, error)
// DiscardForAuthKey 级联 discard 绑定该 perm auth_key 的全部活跃密聊(设备登出/授权撤销),
// 返回实际迁移到 discarded 的密聊供通知对端。
DiscardForAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error)
GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error)
SendEncrypted(ctx context.Context, chatID int, viewerUserID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error)
SendEncrypted(ctx context.Context, chatID int, viewerUserID, viewerAuthKeyID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error)
ListNewMessages(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error)
DeviceReservedQts(ctx context.Context, deviceAuthKeyID int64) (int, error)
AckQueue(ctx context.Context, deviceAuthKeyID int64, maxQts int) error

View file

@ -9,11 +9,21 @@ import (
)
func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) {
list, err := r.pinnedDialogsBaseList(ctx, userID, folderID)
key := fmt.Sprintf("%d:%d:%d", userID, folderID, LayerFrom(ctx))
value, err, _ := r.dialogsPinnedListSF.Do(key, func() (any, error) {
list, err := r.pinnedDialogsBaseList(ctx, userID, folderID)
if err != nil {
return domain.DialogList{}, err
}
return r.withCommunityDialogList(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list)
})
if err != nil {
return domain.DialogList{}, err
}
return r.withCommunityDialogList(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list)
if list, ok := value.(domain.DialogList); ok {
return list, nil
}
return domain.DialogList{}, nil
}
func (r *Router) pinnedDialogsBaseList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) {

View file

@ -48,7 +48,7 @@ func TestDialogFilterFromGetDialogsRequestUsesAllParameters(t *testing.T) {
}
}
func TestMessagesGetDialogsReturnsNotModifiedFromFullListHash(t *testing.T) {
func TestMessagesGetDialogsUnknownHashReturnsFullListToRefreshPeerMetadata(t *testing.T) {
dialogs := &captureDialogs{list: domain.DialogList{Count: 3, Hash: 77}}
r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
@ -65,12 +65,11 @@ func TestMessagesGetDialogsReturnsNotModifiedFromFullListHash(t *testing.T) {
if err != nil {
t.Fatalf("dispatch: %v", err)
}
got, ok := enc.(*tg.MessagesDialogsNotModified)
if !ok {
t.Fatalf("response = %T, want *tg.MessagesDialogsNotModified", enc)
if _, ok := enc.(*tg.MessagesDialogsNotModified); ok {
t.Fatalf("response = %T, want full dialogs after unknown/invalidation hash", enc)
}
if got.Count != 3 || dialogs.filter.Hash != 77 {
t.Fatalf("not modified = %+v filter %+v, want count/hash from service", got, dialogs.filter)
if dialogs.filter.Hash != 77 || dialogs.getDialogsCalls != 1 {
t.Fatalf("filter %+v full calls %d, want one authoritative load", dialogs.filter, dialogs.getDialogsCalls)
}
}
@ -1090,8 +1089,17 @@ func TestMessagesGetDialogsTDesktopInitialPageMergesPinnedHeader(t *testing.T) {
if dialogs.getDialogsCalls != 2 || len(dialogs.filters) != 2 {
t.Fatalf("GetDialogs calls = %d filters %+v, want normal + pinned", dialogs.getDialogsCalls, dialogs.filters)
}
if !dialogs.filters[0].ExcludePinned || !dialogs.filters[1].PinnedOnly {
t.Fatalf("filters = %+v, want exclude-pinned then pinned-only", dialogs.filters)
var excludePinnedCalls, pinnedOnlyCalls int
for _, filter := range dialogs.filters {
if filter.ExcludePinned {
excludePinnedCalls++
}
if filter.PinnedOnly {
pinnedOnlyCalls++
}
}
if excludePinnedCalls != 1 || pinnedOnlyCalls != 1 {
t.Fatalf("filters = %+v, want one exclude-pinned and one pinned-only load", dialogs.filters)
}
if len(out.Dialogs) != 4 {
t.Fatalf("dialogs = %d, want archive + two pinned + normal", len(out.Dialogs))
@ -1201,11 +1209,13 @@ func TestPinnedDialogsListSingleflightsConcurrentStartupLoads(t *testing.T) {
entered: make(chan struct{}),
release: make(chan struct{}),
}
r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
communities := &countingPinnedCommunities{}
r := New(Config{}, Deps{Dialogs: dialogs, Communities: communities}, zaptest.NewLogger(t), clock.System)
ctx := WithLayer(context.Background(), communitiesLayer)
errs := make(chan error, 2)
results := make(chan domain.DialogList, 2)
call := func() {
list, err := r.pinnedDialogsList(context.Background(), userID, domain.DialogMainFolderID)
list, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID)
errs <- err
results <- list
}
@ -1225,6 +1235,28 @@ func TestPinnedDialogsListSingleflightsConcurrentStartupLoads(t *testing.T) {
if calls := dialogs.pinnedCalls(); calls != 1 {
t.Fatalf("pinned GetDialogs calls = %d, want singleflight to share one in-flight load", calls)
}
if calls := communities.listJoinedCalls(); calls != 1 {
t.Fatalf("pinned community ListJoined calls = %d, want projected singleflight to share one aggregate load", calls)
}
}
type countingPinnedCommunities struct {
CommunitiesService
mu sync.Mutex
calls int
}
func (s *countingPinnedCommunities) ListJoined(context.Context, int64) ([]domain.CommunityView, error) {
s.mu.Lock()
s.calls++
s.mu.Unlock()
return nil, nil
}
func (s *countingPinnedCommunities) listJoinedCalls() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
type blockingPinnedDialogs struct {

View file

@ -0,0 +1,233 @@
package rpc
import (
"context"
"errors"
"fmt"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type strictDifferenceUsers struct {
mapUsersService
maxBatch int
capacityErr error
failErr error
calls [][]int64
capacityFailure int
}
func (s *strictDifferenceUsers) ByIDs(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
s.calls = append(s.calls, append([]int64(nil), ids...))
if s.failErr != nil {
return nil, s.failErr
}
if s.maxBatch > 0 && len(ids) > s.maxBatch {
s.capacityFailure++
return nil, fmt.Errorf("%w: test batch %d", s.capacityErr, len(ids))
}
return s.mapUsersService.ByIDs(ctx, viewerUserID, ids)
}
type strictDifferenceChannels struct {
*appchannels.Service
difference domain.ChannelDifference
}
func (s *strictDifferenceChannels) GetDifference(context.Context, int64, domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
return s.difference, nil
}
func strictDifferenceUsersAndRefs(count int) ([]int64, []domain.Peer, []domain.User, map[int64]domain.User) {
ids := make([]int64, count)
peers := make([]domain.Peer, count)
raw := make([]domain.User, count)
projected := make(map[int64]domain.User, count)
for i := range ids {
id := int64(2_000_000_000 + i)
ids[i] = id
peers[i] = domain.Peer{Type: domain.PeerTypeUser, ID: id}
raw[i] = domain.User{ID: id, AccessHash: id + 10, Phone: "raw-secret-phone", FirstName: "raw"}
projected[id] = domain.User{ID: id, FirstName: "projected"}
}
return ids, peers, raw, projected
}
func assertStrictDifferenceUsers(t *testing.T, users []tg.UserClass, want int) {
t.Helper()
if len(users) != want {
t.Fatalf("projected users = %d, want %d", len(users), want)
}
for _, item := range users {
user, ok := item.(*tg.User)
if !ok {
t.Fatalf("projected user = %T, want *tg.User", item)
}
if user.Phone != "" {
t.Fatalf("raw phone leaked for user %d: %q", user.ID, user.Phone)
}
}
}
func TestViewerPeerCacheStrictProjectionSplitsAllCapacityErrorsAndRejectsMissing(t *testing.T) {
capacityErrors := map[string]error{
"privacy_memberships": store.ErrActiveChannelMemberPairsLimit,
"owner_union": appusers.ErrBatchUsersLimit,
"sparse_cells": appusers.ErrBatchViewerCells,
}
for name, capacityErr := range capacityErrors {
t.Run(name, func(t *testing.T) {
ids, _, _, projected := strictDifferenceUsersAndRefs(9)
users := &strictDifferenceUsers{
mapUsersService: mapUsersService{users: projected},
maxBatch: 2,
capacityErr: capacityErr,
}
r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
got, err := newViewerPeerCache(r).usersForIDsStrict(context.Background(), 1_900_000_001, ids)
if err != nil || len(got) != len(ids) || users.capacityFailure == 0 {
t.Fatalf("strict projection users=%d failures=%d err=%v, want complete split result", len(got), users.capacityFailure, err)
}
})
}
missingID := int64(2_100_000_001)
r := New(Config{}, Deps{Users: &strictDifferenceUsers{
mapUsersService: mapUsersService{users: map[int64]domain.User{}},
}}, zaptest.NewLogger(t), clock.System)
got, err := newViewerPeerCache(r).usersForIDsStrict(context.Background(), 1_900_000_001, []int64{missingID, domain.OfficialSystemUserID})
if !errors.Is(err, ErrDurableUserProjectionIncomplete) || got != nil {
t.Fatalf("strict incomplete projection = %+v, %v; want nil ErrDurableUserProjectionIncomplete", got, err)
}
}
func TestUpdatesGetDifferenceStrictProjectionChunksAndSplitsCapacity(t *testing.T) {
const viewerID = int64(1_900_000_001)
ids, peers, raw, projected := strictDifferenceUsersAndRefs(maxPeerProjectionUsersPerBatch + 1)
users := &strictDifferenceUsers{
mapUsersService: mapUsersService{users: projected},
maxBatch: 250,
capacityErr: appusers.ErrBatchUsersLimit,
}
updates := &captureUpdates{state: domain.UpdateState{Pts: 42, Date: 1700000000}}
updates.difference = &domain.UpdateDifference{
State: updates.state,
Events: []domain.UpdateEvent{{
Type: domain.UpdateEventPinnedDialogs, Pts: 42, PtsCount: 1, Peers: peers, Users: raw,
}},
}
r := New(Config{}, Deps{Users: users, Updates: updates}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetDifference(WithUserID(context.Background(), viewerID), &tg.UpdatesGetDifferenceRequest{})
if err != nil {
t.Fatalf("updates.getDifference: %v", err)
}
full, ok := got.(*tg.UpdatesDifference)
if !ok || full.State.Pts != 42 {
t.Fatalf("difference = %T %+v, want full pts 42", got, got)
}
assertStrictDifferenceUsers(t, full.Users, len(ids))
if users.capacityFailure == 0 || len(users.calls) < 3 {
t.Fatalf("resolver calls=%d capacity failures=%d, want bounded recursive split", len(users.calls), users.capacityFailure)
}
for i, call := range users.calls {
if len(call) > maxPeerProjectionUsersPerBatch {
t.Fatalf("resolver call %d size=%d exceeds outer chunk %d", i, len(call), maxPeerProjectionUsersPerBatch)
}
}
}
func TestUpdatesGetChannelDifferenceStrictProjectionChunksAndSplitsCapacity(t *testing.T) {
ctx := context.Background()
const viewerID = int64(1_900_000_001)
ids, _, raw, projected := strictDifferenceUsersAndRefs(maxPeerProjectionUsersPerBatch + 1)
users := &strictDifferenceUsers{
mapUsersService: mapUsersService{users: projected},
maxBatch: 250,
capacityErr: appusers.ErrBatchViewerCells,
}
base := appchannels.NewService(memory.NewChannelStore())
created, err := base.CreateChannel(ctx, viewerID, domain.CreateChannelRequest{Title: "strict diff", Broadcast: true, Date: 1700000000})
if err != nil {
t.Fatal(err)
}
channels := &strictDifferenceChannels{Service: base, difference: domain.ChannelDifference{
Channel: created.Channel,
Self: domain.ChannelMember{ChannelID: created.Channel.ID, UserID: viewerID, Status: domain.ChannelMemberActive},
OtherUpdates: []domain.ChannelUpdateEvent{{
Type: domain.ChannelUpdateDeleteMessages, Pts: 77, PtsCount: 1, MessageIDs: []int{1}, UserIDs: ids,
}},
Users: raw, Pts: 77, Final: true,
}}
r := New(Config{}, Deps{Users: users, Channels: channels}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewerID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{}, Limit: 100,
})
if err != nil {
t.Fatalf("updates.getChannelDifference: %v", err)
}
full, ok := got.(*tg.UpdatesChannelDifference)
if !ok || full.Pts != 77 {
t.Fatalf("channel difference = %T %+v, want full pts 77", got, got)
}
assertStrictDifferenceUsers(t, full.Users, len(ids))
if users.capacityFailure == 0 || len(users.calls) < 3 {
t.Fatalf("resolver calls=%d capacity failures=%d, want bounded recursive split", len(users.calls), users.capacityFailure)
}
}
func TestDurableDifferencesFailClosedOnOrdinaryUserResolverError(t *testing.T) {
boom := errors.New("projection unavailable")
const viewerID = int64(1_900_000_001)
ids, peers, raw, projected := strictDifferenceUsersAndRefs(1)
t.Run("account", func(t *testing.T) {
users := &strictDifferenceUsers{mapUsersService: mapUsersService{users: projected}, failErr: boom}
updates := &captureUpdates{state: domain.UpdateState{Pts: 9, Date: 1700000000}}
updates.difference = &domain.UpdateDifference{State: updates.state, Events: []domain.UpdateEvent{{
Type: domain.UpdateEventPinnedDialogs, Pts: 9, PtsCount: 1, Peers: peers, Users: raw,
}}}
r := New(Config{}, Deps{Users: users, Updates: updates}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetDifference(WithUserID(context.Background(), viewerID), &tg.UpdatesGetDifferenceRequest{})
if err == nil || got != nil || len(users.calls) != 1 || updates.commitCalls != 0 {
t.Fatalf("account difference=%T err=%v calls=%d commits=%d, want fail-closed nil without raw phone/PTS advance", got, err, len(users.calls), updates.commitCalls)
}
})
t.Run("channel", func(t *testing.T) {
ctx := context.Background()
users := &strictDifferenceUsers{mapUsersService: mapUsersService{users: projected}, failErr: boom}
base := appchannels.NewService(memory.NewChannelStore())
created, err := base.CreateChannel(ctx, viewerID, domain.CreateChannelRequest{Title: "strict error", Broadcast: true, Date: 1700000000})
if err != nil {
t.Fatal(err)
}
channels := &strictDifferenceChannels{Service: base, difference: domain.ChannelDifference{
Channel: created.Channel,
Self: domain.ChannelMember{ChannelID: created.Channel.ID, UserID: viewerID, Status: domain.ChannelMemberActive},
OtherUpdates: []domain.ChannelUpdateEvent{{
Type: domain.ChannelUpdateDeleteMessages, Pts: 10, PtsCount: 1, MessageIDs: []int{1}, UserIDs: ids,
}},
Users: raw, Pts: 10, Final: true,
}}
r := New(Config{}, Deps{Users: users, Channels: channels}, zaptest.NewLogger(t), clock.System)
got, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewerID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{}, Limit: 100,
})
if err == nil || got != nil || len(users.calls) != 1 {
t.Fatalf("channel difference=%T err=%v calls=%d, want fail-closed nil without raw phone/PTS response", got, err, len(users.calls))
}
})
}

View file

@ -77,7 +77,11 @@ func (r *Router) prepareRPCDispatchContext(
}
if r.log != nil {
if tInfo := r.clock.Now(); tInfo.Sub(preStart) > 50*time.Millisecond {
r.log.Info("slow pre-handler",
// Successful slow paths are already represented by per-method latency
// and request-scoped database-work metrics. Keep their request detail
// only under explicit Debug logging; INFO must not become synchronous
// per-RPC I/O during a concentrated login burst.
r.log.Debug("slow pre-handler",
zap.String("method", method),
zap.Duration("pre_total", tInfo.Sub(preStart)),
zap.Duration("auth_resolve", tAuth.Sub(preStart)),

View file

@ -0,0 +1,50 @@
package rpc
import (
"context"
"sync"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type loggingStepClock struct {
mu sync.Mutex
now time.Time
step time.Duration
}
func (c *loggingStepClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(c.step)
return c.now
}
func (*loggingStepClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) }
func (*loggingStepClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) }
func TestSlowSuccessfulPreHandlerDoesNotEnterInfoHotPath(t *testing.T) {
infoCore, infoLogs := observer.New(zap.InfoLevel)
infoRouter := New(Config{}, Deps{}, zap.New(infoCore), &loggingStepClock{step: 25 * time.Millisecond})
if _, _, err := infoRouter.prepareRPCDispatchContext(context.Background(), [8]byte{1}, 2, 0, "help.getConfig"); err != nil {
t.Fatal(err)
}
if got := infoLogs.FilterMessage("slow pre-handler").Len(); got != 0 {
t.Fatalf("slow successful pre-handler emitted %d Info logs, want none", got)
}
debugCore, debugLogs := observer.New(zap.DebugLevel)
debugRouter := New(Config{}, Deps{}, zap.New(debugCore), &loggingStepClock{step: 25 * time.Millisecond})
if _, _, err := debugRouter.prepareRPCDispatchContext(context.Background(), [8]byte{1}, 2, 0, "help.getConfig"); err != nil {
t.Fatal(err)
}
entries := debugLogs.FilterMessage("slow pre-handler").All()
if len(entries) != 1 || entries[0].Level != zap.DebugLevel {
t.Fatalf("slow pre-handler debug entries=%v, want one Debug entry", entries)
}
}

View file

@ -83,9 +83,10 @@ func (r *Router) ResolveNegotiatedSessionLayerEvidence(
// AdvanceNegotiatedSessionLayerEvidence commits the same-session watermark and
// auth-key-wide default atomically before any connection/profile/readiness
// state is mutated. publishShared is true only when this observation is still
// the durable shared default; an old duplicate cannot overwrite a newer
// session's local cache after restart.
// state is mutated. publishShared is true only for a different durable profile
// generation which still owns the shared default. A same-generation msg_id
// advance remains fully durable and fresh for the exact session, but does not
// re-read/re-publish the unchanged auth-key default.
func (r *Router) AdvanceNegotiatedSessionLayerEvidence(
ctx context.Context,
rawAuthKeyID [8]byte,
@ -106,6 +107,7 @@ func (r *Router) AdvanceNegotiatedSessionLayerEvidence(
}
return currentLayer, currentMsgID, currentLayer == layer && currentMsgID == msgID, nil
}
previousObservationID, hadPreviousGeneration := r.cachedDurableSessionLayerObservation(rawAuthKeyID, sessionID)
current, _, err := r.deps.AuthKeySessionLayers.AdvanceSessionLayer(
ctx,
rawAuthKeyID,
@ -125,7 +127,23 @@ func (r *Router) AdvanceNegotiatedSessionLayerEvidence(
if err := r.cacheResolvedDurableSessionLayer(rawAuthKeyID, sessionID, current); err != nil {
return 0, 0, false, err
}
return current.Layer, current.MessageID, current.SharedDefault, nil
generationChanged := !hadPreviousGeneration || previousObservationID != current.ObservationID
return current.Layer, current.MessageID, current.SharedDefault && generationChanged, nil
}
func (r *Router) cachedDurableSessionLayerObservation(rawAuthKeyID [8]byte, sessionID int64) (int64, bool) {
if r == nil || rawAuthKeyID == ([8]byte{}) || sessionID == 0 {
return 0, false
}
key := clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
now := r.clock.Now()
r.exactProfileMu.RLock()
entry, found := r.exactProfiles[key]
r.exactProfileMu.RUnlock()
if !found || entry.observationID <= 0 || !now.Before(entry.expiresAt) {
return 0, false
}
return entry.observationID, true
}
// cacheResolvedDurableSessionLayer updates only the bounded typed accelerator;
@ -184,12 +202,18 @@ func (r *Router) cacheResolvedDurableSessionLayer(
// Advance which already refreshed the local cache. Do not roll it back.
return nil
case current.observationID == entry.observationID && entry.observationID > 0:
if current.layer != entry.layer || current.msgID != entry.msgID {
return fmt.Errorf("%w: observation %d maps to (%d,%d) and (%d,%d)",
store.ErrAuthKeySessionLayerConflict, entry.observationID,
current.layer, current.msgID, entry.layer, entry.msgID)
if current.layer != entry.layer {
return fmt.Errorf("%w: observation %d maps to Layer %d and %d",
store.ErrAuthKeySessionLayerConflict, entry.observationID, current.layer, entry.layer)
}
// The store row may have an authoritative expiry refresh; replace it.
if current.msgID > entry.msgID {
// One same-Layer fast advance refreshed this process after the
// current DB read linearized. Observation identifies the stable
// Layer generation; msg_id remains its monotonic high-water mark.
return nil
}
// The store row may have a newer same-generation high-water mark or
// an authoritative expiry refresh; replace it.
case entry.observationID <= 0 && current.msgID > entry.msgID:
// Defensive compatibility for an old custom store without observation
// ids. Production stores always take the branches above.

View file

@ -41,7 +41,7 @@ func TestDurableSessionLayerSurvivesRestartAndRejectsOldSelectorRollback(t *test
t.Fatalf("restart restore = (%d,%d,%v,%v)", layer, msgID, found, err)
}
layer, msgID, publish, err := restarted.AdvanceNegotiatedSessionLayerEvidence(ctx, authKeyID, 10, 225, olderID)
if err != nil || layer != 227 || msgID != newerID || !publish {
if err != nil || layer != 227 || msgID != newerID || publish {
t.Fatalf("old selector after restart = (%d,%d,%v,%v)", layer, msgID, publish, err)
}
key, found, err := keys.Get(ctx, authKeyID)
@ -50,6 +50,34 @@ func TestDurableSessionLayerSurvivesRestartAndRejectsOldSelectorRollback(t *test
}
}
func TestDurableSessionLayerSameGenerationSkipsSharedPublication(t *testing.T) {
ctx := context.Background()
keys := memory.NewAuthKeyStore()
authKeyID := [8]byte{1, 0xb3}
if err := keys.Save(ctx, store.AuthKeyData{ID: authKeyID}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
msgIDs := proto.NewMessageIDGen(func() time.Time { return now })
firstID := int64(msgIDs.New(proto.MessageFromClient))
secondID := int64(msgIDs.New(proto.MessageFromClient))
r := New(Config{}, Deps{AuthKeySessionLayers: keys}, zaptest.NewLogger(t), clock.System)
if layer, msgID, publish, err := r.AdvanceNegotiatedSessionLayerEvidence(ctx, authKeyID, 11, 227, firstID); err != nil || layer != 227 || msgID != firstID || !publish {
t.Fatalf("first generation = (%d,%d,%v,%v)", layer, msgID, publish, err)
}
first, found, err := keys.GetSessionLayer(ctx, authKeyID, 11)
if err != nil || !found {
t.Fatalf("first row = (%+v,%v,%v)", first, found, err)
}
if layer, msgID, publish, err := r.AdvanceNegotiatedSessionLayerEvidence(ctx, authKeyID, 11, 227, secondID); err != nil || layer != 227 || msgID != secondID || publish {
t.Fatalf("same generation = (%d,%d,%v,%v)", layer, msgID, publish, err)
}
second, found, err := keys.GetSessionLayer(ctx, authKeyID, 11)
if err != nil || !found || second.ObservationID != first.ObservationID {
t.Fatalf("same-generation row = (%+v,%v,%v), first observation %d", second, found, err, first.ObservationID)
}
}
func TestDurableSessionLayerResolveRefreshesStaleRouterFromSharedStore(t *testing.T) {
ctx := context.Background()
keys := memory.NewAuthKeyStore()
@ -94,12 +122,12 @@ func TestDurableSessionLayerFutureProfileCanBeCorrectedByGreaterSelector(t *test
now := time.Now().UTC()
futureID := int64(proto.NewMessageIDGen(func() time.Time { return now }).New(proto.MessageFromClient))
correctID := int64(proto.NewMessageIDGen(func() time.Time { return now.Add(time.Second) }).New(proto.MessageFromClient))
if _, applied, err := keys.AdvanceSessionLayer(ctx, authKeyID, 20, 229, futureID); err != nil || !applied {
if _, applied, err := keys.AdvanceSessionLayer(ctx, authKeyID, 20, 230, futureID); err != nil || !applied {
t.Fatalf("seed future evidence = applied %v err %v", applied, err)
}
r := New(Config{}, Deps{AuthKeySessionLayers: keys}, zaptest.NewLogger(t), clock.System)
layer, msgID, found, err := r.ResolveNegotiatedSessionLayerEvidence(ctx, authKeyID, 20)
if err != nil || !found || layer != 229 || msgID != futureID {
if err != nil || !found || layer != 230 || msgID != futureID {
t.Fatalf("future restore = (%d,%d,%v,%v)", layer, msgID, found, err)
}
if _, _, cached := r.NegotiatedSessionLayerEvidence(authKeyID, 20); cached {
@ -177,6 +205,36 @@ func TestDurableSessionLayerCacheOrdersRebuiltRowsByObservationID(t *testing.T)
}
}
func TestDurableSessionLayerCacheAdvancesHighWaterWithinObservation(t *testing.T) {
now := time.Now().UTC()
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
authKeyID := [8]byte{3, 0xc6}
const sessionID = int64(306)
first := store.AuthKeySessionLayer{
Layer: 227, MessageID: 10_000, ObservationID: 10, ExpiresAt: now.Add(time.Minute),
}
if err := r.cacheResolvedDurableSessionLayer(authKeyID, sessionID, first); err != nil {
t.Fatal(err)
}
advanced := first
advanced.MessageID = 20_000
advanced.ExpiresAt = now.Add(2 * time.Minute)
if err := r.cacheResolvedDurableSessionLayer(authKeyID, sessionID, advanced); err != nil {
t.Fatal(err)
}
if layer, msgID, found := r.NegotiatedSessionLayerEvidence(authKeyID, sessionID); !found || layer != 227 || msgID != 20_000 {
t.Fatalf("same-observation advance = (%d,%d,%v)", layer, msgID, found)
}
stale := first
stale.MessageID = 15_000
if err := r.cacheResolvedDurableSessionLayer(authKeyID, sessionID, stale); err != nil {
t.Fatal(err)
}
if layer, msgID, found := r.NegotiatedSessionLayerEvidence(authKeyID, sessionID); !found || layer != 227 || msgID != 20_000 {
t.Fatalf("stale same-observation read rolled cache back = (%d,%d,%v)", layer, msgID, found)
}
}
func TestDurableSessionLayerAvailabilityErrorCarriesStructuralMarker(t *testing.T) {
boom := errors.New("database unavailable")
r := New(Config{}, Deps{AuthKeySessionLayers: unavailableSessionLayerStore{err: boom}}, zaptest.NewLogger(t), clock.System)
@ -201,7 +259,7 @@ func TestDurableInheritedLayerRevalidatesEachNewSession(t *testing.T) {
if layer, found, err := r.ResolveInheritedAuthKeyLayer(ctx, authKeyID); err != nil || !found || layer != 225 {
t.Fatalf("initial default = (%d,%v,%v)", layer, found, err)
}
auth.authKeyClientInfos[authKeyID] = domain.AuthKeyClientInfo{Layer: 229, LayerObservationID: 2}
auth.authKeyClientInfos[authKeyID] = domain.AuthKeyClientInfo{Layer: 230, LayerObservationID: 2}
if layer, found, err := r.ResolveInheritedAuthKeyLayer(ctx, authKeyID); err != nil || !found || layer != 0 {
t.Fatalf("future authoritative default = (%d,%v,%v)", layer, found, err)
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
@ -14,9 +15,9 @@ import (
// 私聊端对端加密Secret Chat / encrypted chat握手 RPC handler。状态机与 DH 校验
// 归 app/secretchat本文件只做鉴权、入参校验、TL 转换与在线推送编排。
//
// P0 范围:requestEncryption / acceptEncryption / discardEncryption 的握手闭环 +
// updateEncryption 在线推送(账号级 pushUserMessage与 phone 同套)。设备级定向、
// durable 离线 getDifference 补偿、qts 消息投递sendEncrypted 等)见 P1
// requestEncryption / acceptEncryption / discardEncryption 的握手闭环 +
// updateEncryption 在线设备定向、durable 离线 getDifference 补偿与输家设备收敛;
// qts 消息投递sendEncrypted 等)见 encrypted_messages.go
// 设计 docs/secret-chat-module.md。服务端是盲中继永不接触共享密钥与明文。
// secretChatErr 把 app/secretchat + domain 业务错误映射为 RPC_ERROR。
@ -28,6 +29,8 @@ func secretChatErr(err error) error {
return encryptionAlreadyAcceptedErr()
case errors.Is(err, domain.ErrSecretChatAlreadyDeclined):
return encryptionAlreadyDeclinedErr()
case errors.Is(err, domain.ErrSecretChatRandomIDDuplicate):
return secretChatRandomIDDuplicateErr()
case errors.Is(err, domain.ErrSecretChatNotFound):
return chatIDInvalidErr()
default:
@ -56,9 +59,9 @@ func businessAuthKeyIDFrom(ctx context.Context) (int64, bool) {
return businessAuthKeyInt64(id), true
}
// pushUpdateEncryption 把 targetUserID 视角的 updateEncryption 推给其全部在线设备。
// P0 用账号级在线推送(设备级定向 + 离线补偿见 P1
func (r *Router) pushUpdateEncryption(ctx context.Context, targetUserID int64, chat domain.SecretChat, logMessage string) {
// pushUpdateEncryption 把 updateEncryption 投给目标账号或精确绑定设备。targetAuthKeyID=0
// 仅允许用于 accept 前邀请/撤回accept 后必须非零,缺少定向 binder 时 fail-closed
func (r *Router) pushUpdateEncryption(ctx context.Context, targetUserID, targetAuthKeyID int64, chat domain.SecretChat, logMessage string) {
now := int(r.clock.Now().Unix())
upd := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateEncryption{
@ -70,7 +73,45 @@ func (r *Router) pushUpdateEncryption(ctx context.Context, targetUserID int64, c
Date: now,
Seq: 0,
}
r.pushUserMessage(ctx, targetUserID, logMessage, upd)
if targetAuthKeyID == 0 {
r.pushUserMessage(ctx, targetUserID, logMessage, upd)
return
}
if targeted, ok := r.deps.Sessions.(AuthKeyTargetedSessionBinder); ok {
_, _ = targeted.PushToUserAuthKey(ctx, targetUserID, deviceAuthKeyBytes(targetAuthKeyID), proto.MessageFromServer, upd)
return
}
r.log.Error("secret chat targeted session binder unavailable",
zap.String("update", logMessage),
zap.Int64("target_user_id", targetUserID),
zap.Int64("target_auth_key_id", targetAuthKeyID))
}
// pushAcceptedLoserDiscarded 让 participant 账号中除获胜 business auth key 外的在线设备
// 删除 requested 幽灵。离线/未就绪设备由 accept 后新增的账号级 state event 收敛。
func (r *Router) pushAcceptedLoserDiscarded(ctx context.Context, chat domain.SecretChat, date int) {
if chat.ParticipantUserID == 0 || chat.ParticipantAuthKeyID == 0 {
return
}
upd := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateEncryption{
Chat: &tg.EncryptedChatDiscarded{ID: chat.ID, HistoryDeleted: true},
Date: date,
}},
Users: r.tgUsersForIDs(ctx, chat.ParticipantUserID, []int64{chat.AdminUserID, chat.ParticipantUserID}),
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
if targeted, ok := r.deps.Sessions.(AuthKeyTargetedSessionBinder); ok {
_, _ = targeted.PushToUserExceptBusinessAuthKey(ctx, chat.ParticipantUserID,
deviceAuthKeyBytes(chat.ParticipantAuthKeyID), proto.MessageFromServer, upd, r.cfg.OutboundPushTimeout)
return
}
r.log.Error("secret chat targeted session binder unavailable",
zap.String("update", "secret chat accept loser discarded"),
zap.Int64("target_user_id", chat.ParticipantUserID),
zap.Int64("exclude_auth_key_id", chat.ParticipantAuthKeyID))
}
// recordEncryptionEventBestEffort 写入 durable updateEncryption 状态事件供离线设备
@ -106,7 +147,7 @@ func (r *Router) discardSecretChatsForAuthKey(ctx context.Context, businessAuthK
}
// 对端绑定设备已知则 device-level 定向建链前未绑定0则账号级。
r.recordEncryptionEventBestEffort(ctx, chat.ID, peer, chat.PeerAuthKeyOf(ownerUserID), now)
r.pushUpdateEncryption(ctx, peer, chat, "secret chat discarded on peer logout/revoke")
r.pushUpdateEncryption(ctx, peer, chat.PeerAuthKeyOf(ownerUserID), chat, "secret chat discarded on peer logout/revoke")
}
}
@ -114,6 +155,9 @@ func (r *Router) onMessagesRequestEncryption(ctx context.Context, req *tg.Messag
if req == nil {
return nil, inputRequestInvalidErr()
}
if req.RandomID == 0 || int(int32(req.RandomID)) != req.RandomID {
return nil, secretChatRandomIDDuplicateErr()
}
if r.deps.SecretChats == nil || r.deps.Users == nil {
return nil, notImplementedErr()
}
@ -152,7 +196,7 @@ func (r *Router) onMessagesRequestEncryption(ctx context.Context, req *tg.Messag
// 建链前邀请是账号级targetAuthKeyID=0participant 所有设备(含离线)可见。
r.recordEncryptionEventBestEffort(ctx, chat.ID, chat.ParticipantUserID, 0, chat.Date)
// 推接受方全部在线设备 encryptedChatRequested携 g_a。离线设备经 getDifference 补回。
r.pushUpdateEncryption(ctx, chat.ParticipantUserID, chat, "secret chat requested")
r.pushUpdateEncryption(ctx, chat.ParticipantUserID, 0, chat, "secret chat requested")
// 发起方同步收 encryptedChatWaiting无 g_a
return tgEncryptedChatForViewer(chat, adminID), nil
}
@ -177,11 +221,15 @@ func (r *Router) onMessagesAcceptEncryption(ctx context.Context, req *tg.Message
if err != nil {
return nil, secretChatErr(err)
}
now := int(r.clock.Now().Unix())
// 建链完成定向发起方绑定设备device-level离线发起方经 getDifference 补回成型态。
r.recordEncryptionEventBestEffort(ctx, chat.ID, chat.AdminUserID, chat.AdminAuthKeyID, int(r.clock.Now().Unix()))
// 推发起方全部在线设备 encryptedChatGAOrB=g_b, key_fingerprint发起方据此
// 算共享密钥并比对指纹。
r.pushUpdateEncryption(ctx, chat.AdminUserID, chat, "secret chat accepted")
r.recordEncryptionEventBestEffort(ctx, chat.ID, chat.AdminUserID, chat.AdminAuthKeyID, now)
// 给 participant 账号新增一次收敛事件:获胜 auth key 跳过,其它已见/未见邀请的设备
// 均投影为 discarded避免 requested 幽灵与 future-device normal 泄漏。
r.recordEncryptionEventBestEffort(ctx, chat.ID, chat.ParticipantUserID, 0, now)
// 仅推发起方绑定设备 encryptedChatGAOrB=g_b, key_fingerprint
r.pushUpdateEncryption(ctx, chat.AdminUserID, chat.AdminAuthKeyID, chat, "secret chat accepted")
r.pushAcceptedLoserDiscarded(ctx, chat, now)
// 接受方同步收 encryptedChatGAOrB=g_a
return tgEncryptedChatForViewer(chat, userID), nil
}
@ -197,7 +245,11 @@ func (r *Router) onMessagesDiscardEncryption(ctx context.Context, req *tg.Messag
if err != nil {
return false, err
}
chat, already, err := r.deps.SecretChats.DiscardEncryption(ctx, req.ChatID, userID, req.DeleteHistory)
deviceAuthKeyID, ok := businessAuthKeyIDFrom(ctx)
if !ok {
return false, internalErr()
}
chat, already, err := r.deps.SecretChats.DiscardEncryption(ctx, req.ChatID, userID, deviceAuthKeyID, req.DeleteHistory)
if err != nil {
return false, secretChatErr(err)
}
@ -206,7 +258,7 @@ func (r *Router) onMessagesDiscardEncryption(ctx context.Context, req *tg.Messag
// 对端绑定设备已知则 device-level建链前未绑定则账号级同 requested 集合)。
if peer := chat.PeerOf(userID); peer != 0 {
r.recordEncryptionEventBestEffort(ctx, chat.ID, peer, chat.PeerAuthKeyOf(userID), int(r.clock.Now().Unix()))
r.pushUpdateEncryption(ctx, peer, chat, "secret chat discarded")
r.pushUpdateEncryption(ctx, peer, chat.PeerAuthKeyOf(userID), chat, "secret chat discarded")
}
}
return true, nil

View file

@ -2,12 +2,18 @@ package rpc
import (
"context"
"crypto/sha1"
"encoding/binary"
"math/big"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
appphone "telesrv/internal/app/phone"
appsecret "telesrv/internal/app/secretchat"
appupdates "telesrv/internal/app/updates"
appusers "telesrv/internal/app/users"
@ -15,24 +21,6 @@ import (
"telesrv/internal/store/memory"
)
// seqSecretChatIDAllocator 是单调自增的测试 chat id 分配器。
type seqSecretChatIDAllocator struct{ n int }
func (a *seqSecretChatIDAllocator) NextSecretChatID(context.Context) (int, error) {
a.n++
return a.n, nil
}
func (a *seqSecretChatIDAllocator) NextSecretChatIDAtLeast(_ context.Context, floor int) (int, error) {
if a.n < floor {
a.n = floor
}
a.n++
return a.n, nil
}
func (a *seqSecretChatIDAllocator) CurrentSecretChatID(context.Context) (int, error) { return a.n, nil }
func dhParam(lead byte) []byte {
b := make([]byte, 256)
for i := range b {
@ -53,13 +41,15 @@ type encryptedFixture struct {
}
const (
encAdminSession = int64(301)
encPartSession = int64(302)
encAdminSession = int64(301)
encPartSession = int64(302)
encPartOtherSession = int64(303)
)
var (
encAdminAuthKey = [8]byte{1, 0, 0, 0, 0, 0, 0, 0}
encPartAuthKey = [8]byte{2, 0, 0, 0, 0, 0, 0, 0}
encAdminAuthKey = [8]byte{1, 0, 0, 0, 0, 0, 0, 0}
encPartAuthKey = [8]byte{2, 0, 0, 0, 0, 0, 0, 0}
encPartOtherAuthKey = [8]byte{3, 0, 0, 0, 0, 0, 0, 0}
)
func newEncryptedFixture(t *testing.T) *encryptedFixture {
@ -71,7 +61,7 @@ func newEncryptedFixture(t *testing.T) *encryptedFixture {
queueStore := memory.NewEncryptedQueueStore()
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
SecretChats: appsecret.NewService(secretStore, queueStore, &seqSecretChatIDAllocator{}),
SecretChats: appsecret.NewService(secretStore, queueStore),
Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
Files: &fakeFiles{},
Sessions: sessions,
@ -97,6 +87,10 @@ func (f *encryptedFixture) participantCtx() context.Context {
return WithAuthKeyID(WithSessionID(WithUserID(f.ctx, f.participant.ID), encPartSession), encPartAuthKey)
}
func (f *encryptedFixture) participantOtherCtx() context.Context {
return WithAuthKeyID(WithSessionID(WithUserID(f.ctx, f.participant.ID), encPartOtherSession), encPartOtherAuthKey)
}
// encChatPayload 从捕获的推送里取出 updateEncryption 载荷。
func encChatPayload(t *testing.T, rec phonePushRecord) tg.EncryptedChatClass {
t.Helper()
@ -111,6 +105,121 @@ func encChatPayload(t *testing.T, rec phonePushRecord) tg.EncryptedChatClass {
return upd.Chat
}
func secretChatDHFixture(t *testing.T, wantNegativeFingerprint bool) (ga, gb []byte, fingerprint int64) {
t.Helper()
prime := new(big.Int).SetBytes(appphone.DHPrime())
generator := big.NewInt(int64(appphone.DHG))
privateA := new(big.Int).SetBytes(make([]byte, 256))
privateA.SetBit(privateA, 2046, 1)
privateA.Add(privateA, big.NewInt(0x12345))
gaInt := new(big.Int).Exp(generator, privateA, prime)
ga = gaInt.Bytes()
for n := int64(1); n < 128; n++ {
privateB := new(big.Int).SetBit(new(big.Int), 2045, 1)
privateB.Add(privateB, big.NewInt(0x54321+n))
gbInt := new(big.Int).Exp(generator, privateB, prime)
sharedA := new(big.Int).Exp(gbInt, privateA, prime)
sharedB := new(big.Int).Exp(gaInt, privateB, prime)
if sharedA.Cmp(sharedB) != 0 {
t.Fatal("DH fixture derived different shared keys")
}
key := make([]byte, 256)
sharedBytes := sharedA.Bytes()
copy(key[len(key)-len(sharedBytes):], sharedBytes)
digest := sha1.Sum(key)
fingerprint = int64(binary.LittleEndian.Uint64(digest[12:20]))
if (fingerprint < 0) == wantNegativeFingerprint {
return ga, gbInt.Bytes(), fingerprint
}
}
t.Fatalf("could not generate DH fixture with negative=%v fingerprint", wantNegativeFingerprint)
return nil, nil, 0
}
func TestEncryptedChatRealDHHandshakeAcrossExactLayers(t *testing.T) {
for _, negative := range []bool{false, true} {
name := "positive_fingerprint"
if negative {
name = "negative_fingerprint"
}
t.Run(name, func(t *testing.T) {
f := newEncryptedFixture(t)
ga, gb, fingerprint := secretChatDHFixture(t, negative)
waitingClass, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{
UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash},
RandomID: 909,
GA: ga,
})
if err != nil {
t.Fatalf("requestEncryption: %v", err)
}
waiting := waitingClass.(*tg.EncryptedChatWaiting)
if waiting.ID != 909 {
t.Fatalf("waiting id = %d, want request random_id 909", waiting.ID)
}
chat, ok, err := f.store.GetSecretChat(f.ctx, waiting.ID)
if err != nil || !ok {
t.Fatalf("stored requested chat: ok=%v err=%v", ok, err)
}
f.sessions.reset()
if _, err := f.router.onMessagesAcceptEncryption(f.participantCtx(), &tg.MessagesAcceptEncryptionRequest{
Peer: tg.InputEncryptedChat{ChatID: chat.ID, AccessHash: chat.ParticipantAccessHash},
GB: gb,
KeyFingerprint: fingerprint,
}); err != nil {
t.Fatalf("acceptEncryption: %v", err)
}
var adminUpdates *tg.Updates
for _, rec := range f.sessions.records() {
if rec.userID == f.admin.ID {
adminUpdates = rec.msg.(*tg.Updates)
break
}
}
if adminUpdates == nil {
t.Fatal("missing accepted update for the initiating device")
}
for _, profile := range []tlprofile.Profile{
tlprofile.Profile225,
tlprofile.Profile226,
tlprofile.Profile227,
tlprofile.Profile228,
} {
var body bin.Buffer
if err := tlprofile.EncodeObject(profile, adminUpdates, &body); err != nil {
t.Fatalf("encode accepted update for profile %d: %v", profile, err)
}
decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: body.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode accepted update for profile %d: %v", profile, err)
}
updates, ok := decoded.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("profile %d decoded update = %T", profile, decoded)
}
encUpdate, ok := updates.Updates[0].(*tg.UpdateEncryption)
if !ok {
t.Fatalf("profile %d nested update = %T", profile, updates.Updates[0])
}
accepted, ok := encUpdate.Chat.(*tg.EncryptedChat)
if !ok {
t.Fatalf("profile %d chat = %T", profile, encUpdate.Chat)
}
if new(big.Int).SetBytes(accepted.GAOrB).Cmp(new(big.Int).SetBytes(gb)) != 0 {
t.Fatalf("profile %d changed g_b", profile)
}
if accepted.KeyFingerprint != fingerprint {
t.Fatalf("profile %d fingerprint = %d, want %d", profile, accepted.KeyFingerprint, fingerprint)
}
}
})
}
}
func TestEncryptedChatRPCHappyPath(t *testing.T) {
f := newEncryptedFixture(t)
ga := dhParam(0x55)
@ -129,6 +238,9 @@ func TestEncryptedChatRPCHappyPath(t *testing.T) {
if !ok {
t.Fatalf("request response = %T, want *tg.EncryptedChatWaiting", res)
}
if waiting.ID != 777 {
t.Fatalf("waiting id = %d, want request random_id 777", waiting.ID)
}
// 推送给接受方的 encryptedChatRequested携 g_a
recs := f.sessions.records()
if len(recs) != 1 || recs[0].userID != f.participant.ID {
@ -138,8 +250,8 @@ func TestEncryptedChatRPCHappyPath(t *testing.T) {
if !ok {
t.Fatalf("participant payload = %T, want EncryptedChatRequested", encChatPayload(t, recs[0]))
}
if requested.ID != waiting.ID {
t.Fatalf("chat id mismatch admin=%d participant=%d", waiting.ID, requested.ID)
if requested.ID != 777 {
t.Fatalf("participant requested id = %d, want request random_id 777", requested.ID)
}
if string(requested.GA) != string(ga) {
t.Fatal("requested g_a not relayed verbatim")
@ -172,14 +284,29 @@ func TestEncryptedChatRPCHappyPath(t *testing.T) {
if partView.KeyFingerprint != fp {
t.Fatalf("key fingerprint = %x, want %x", partView.KeyFingerprint, fp)
}
// 推送给发起方encryptedChatGAOrB = g_b
// 定向推送给发起设备 encryptedChat并让 participant 其它设备收敛为 discarded
recs = f.sessions.records()
if len(recs) != 1 || recs[0].userID != f.admin.ID {
t.Fatalf("accept push = %+v, want single push to admin %d", recs, f.admin.ID)
if len(recs) != 2 {
t.Fatalf("accept pushes = %+v, want admin accepted + participant loser discarded", recs)
}
adminView, ok := encChatPayload(t, recs[0]).(*tg.EncryptedChat)
var adminRec, loserRec *phonePushRecord
for i := range recs {
switch recs[i].userID {
case f.admin.ID:
adminRec = &recs[i]
case f.participant.ID:
loserRec = &recs[i]
}
}
if adminRec == nil || adminRec.rawAuthKeyID != encAdminAuthKey {
t.Fatalf("admin accept push = %+v, want target auth key %x", adminRec, encAdminAuthKey)
}
if loserRec == nil || loserRec.rawAuthKeyID != encPartAuthKey {
t.Fatalf("loser discard push = %+v, want exclusion auth key %x", loserRec, encPartAuthKey)
}
adminView, ok := encChatPayload(t, *adminRec).(*tg.EncryptedChat)
if !ok {
t.Fatalf("admin payload = %T, want EncryptedChat", encChatPayload(t, recs[0]))
t.Fatalf("admin payload = %T, want EncryptedChat", encChatPayload(t, *adminRec))
}
if string(adminView.GAOrB) != string(gb) {
t.Fatal("admin view GAOrB must be g_b")
@ -187,6 +314,9 @@ func TestEncryptedChatRPCHappyPath(t *testing.T) {
if adminView.KeyFingerprint != fp {
t.Fatal("admin view key fingerprint not relayed byte-for-byte")
}
if discarded, ok := encChatPayload(t, *loserRec).(*tg.EncryptedChatDiscarded); !ok || !discarded.HistoryDeleted {
t.Fatalf("loser payload = %+v, want history-deleting EncryptedChatDiscarded", encChatPayload(t, *loserRec))
}
// --- discardEncryption发起方 ---
f.sessions.reset()
@ -277,6 +407,58 @@ func TestRequestEncryptionSelf(t *testing.T) {
assertPhoneRPCErr(t, err, "USER_ID_INVALID")
}
func TestRequestEncryptionRandomIDContractRPC(t *testing.T) {
f := newEncryptedFixture(t)
request := func(randomID int) (tg.EncryptedChatClass, error) {
return f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{
UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash},
RandomID: randomID,
GA: dhParam(0x55),
})
}
negative, err := request(-808)
if err != nil {
t.Fatalf("negative random_id: %v", err)
}
if got := negative.(*tg.EncryptedChatWaiting).ID; got != -808 {
t.Fatalf("negative waiting id = %d, want -808", got)
}
negativeChat, found, err := f.store.GetSecretChat(f.ctx, -808)
if err != nil || !found {
t.Fatalf("stored negative chat: found=%v err=%v", found, err)
}
accepted, err := f.router.onMessagesAcceptEncryption(f.participantCtx(), &tg.MessagesAcceptEncryptionRequest{
Peer: tg.InputEncryptedChat{ChatID: -808, AccessHash: negativeChat.ParticipantAccessHash},
GB: dhParam(0x66),
KeyFingerprint: 0x1234,
})
if err != nil {
t.Fatalf("accept negative chat id: %v", err)
}
if got := accepted.(*tg.EncryptedChat).ID; got != -808 {
t.Fatalf("accepted id = %d, want -808", got)
}
if _, err := request(0); err == nil {
t.Fatal("zero random_id succeeded, want RANDOM_ID_DUPLICATE")
} else {
assertPhoneRPCErr(t, err, "RANDOM_ID_DUPLICATE")
}
// 同一全局 chat_id 改变握手意图不能被幂等吞掉。
changed := &tg.MessagesRequestEncryptionRequest{
UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash},
RandomID: -808,
GA: dhParam(0x66),
}
if _, err := f.router.onMessagesRequestEncryption(f.adminCtx(), changed); err == nil {
t.Fatal("changed intent succeeded, want RANDOM_ID_DUPLICATE")
} else {
assertPhoneRPCErr(t, err, "RANDOM_ID_DUPLICATE")
}
}
func TestAcceptEncryptionWrongAccessHashRPC(t *testing.T) {
f := newEncryptedFixture(t)
res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{

View file

@ -64,6 +64,9 @@ func (r *Router) onMessagesSendEncryptedFile(ctx context.Context, req *tg.Messag
if req == nil {
return nil, inputRequestInvalidErr()
}
if len(req.Data) > domain.MaxSecretMessageDataBytes {
return nil, dataTooLongErr()
}
if r.deps.SecretChats == nil {
return nil, notImplementedErr()
}
@ -71,11 +74,19 @@ func (r *Router) onMessagesSendEncryptedFile(ctx context.Context, req *tg.Messag
if err != nil {
return nil, err
}
deviceAuthKeyID, ok := businessAuthKeyIDFrom(ctx)
if !ok {
return nil, internalErr()
}
// 先完成 chat/device 授权,避免无权或终态请求先组装 blob、写元数据后才失败。
if _, _, _, err := r.resolveSecretChatPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
fileRef, err := r.resolveInputEncryptedFile(ctx, userID, req.File)
if err != nil {
return nil, err
}
_, stored, err := r.deps.SecretChats.SendEncrypted(ctx, req.Peer.ChatID, userID, req.Peer.AccessHash, domain.SecretMessageDelivery{
_, stored, err := r.deps.SecretChats.SendEncrypted(ctx, req.Peer.ChatID, userID, deviceAuthKeyID, req.Peer.AccessHash, domain.SecretMessageDelivery{
RandomID: req.RandomID,
Bytes: req.Data,
IsService: false,

View file

@ -1,9 +1,13 @@
package rpc
import (
"bytes"
"testing"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
// TestSendEncryptedFileFlowsendEncryptedFile 铸造 EncryptedFile、随消息投递、返回
@ -71,13 +75,70 @@ func TestUploadEncryptedFile(t *testing.T) {
}
}
// TestEncryptedFileLocationKeyinputEncryptedFileLocation → "enc:<id>" 下载 key。
func TestEncryptedFileLocationKey(t *testing.T) {
key, ok := fileLocationKey(&tg.InputEncryptedFileLocation{ID: 123, AccessHash: 456})
if !ok || key != "enc:123" {
t.Fatalf("location key = %q ok %v, want enc:123", key, ok)
// TestEncryptedFileDownloadRequiresCapability密聊 blob 只有在 id+access_hash 元数据能力
// 校验成功后才会转换为内部 enc:<id> key错误 hash 不能触达 Files.GetFile。
func TestEncryptedFileDownloadRequiresCapability(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)
chat, _, _ := f.store.GetSecretChat(f.ctx, chatID)
res, err := f.router.onMessagesUploadEncryptedFile(f.adminCtx(), &tg.MessagesUploadEncryptedFileRequest{
Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash},
File: &tg.InputEncryptedFileUploaded{ID: 888, Parts: 1, KeyFingerprint: 9},
})
if err != nil {
t.Fatalf("uploadEncryptedFile: %v", err)
}
if _, ok := fileLocationKey(&tg.InputEncryptedFileLocation{ID: 0}); ok {
t.Fatal("id=0 must be rejected")
ef := res.(*tg.EncryptedFile)
files := f.router.deps.Files.(*fakeFiles)
files.getFileFound = true
files.getFileChunk = domain.FileChunk{MimeType: "application/octet-stream", Bytes: []byte{1, 2, 3}}
got, err := f.router.onUploadGetFile(f.adminCtx(), &tg.UploadGetFileRequest{
Location: &tg.InputEncryptedFileLocation{ID: ef.ID, AccessHash: ef.AccessHash},
Offset: 0,
Limit: 1024,
})
if err != nil {
t.Fatalf("get encrypted file: %v", err)
}
file, ok := got.(*tg.UploadFile)
if !ok || !bytes.Equal(file.Bytes, []byte{1, 2, 3}) {
t.Fatalf("download = %T %+v", got, got)
}
if files.getFileCalls != 1 || files.getFileRequest.LocationKey != "enc:9001" {
t.Fatalf("GetFile calls/key = %d/%q", files.getFileCalls, files.getFileRequest.LocationKey)
}
_, err = f.router.onUploadGetFile(f.adminCtx(), &tg.UploadGetFileRequest{
Location: &tg.InputEncryptedFileLocation{ID: ef.ID, AccessHash: ef.AccessHash + 1},
Offset: 0,
Limit: 1024,
})
if !tgerr.Is(err, "LOCATION_INVALID") {
t.Fatalf("wrong access hash err = %v", err)
}
if files.getFileCalls != 1 {
t.Fatalf("wrong access hash reached blob store: calls=%d", files.getFileCalls)
}
}
func TestEncryptedDataLimit(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)
chat, _, _ := f.store.GetSecretChat(f.ctx, chatID)
tooLong := make([]byte, domain.MaxSecretMessageDataBytes+1)
_, err := f.router.onMessagesSendEncrypted(f.adminCtx(), &tg.MessagesSendEncryptedRequest{
Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, RandomID: 1, Data: tooLong,
})
if !tgerr.Is(err, "DATA_TOO_LONG") {
t.Fatalf("sendEncrypted oversized err = %v", err)
}
_, err = f.router.onMessagesSendEncryptedFile(f.adminCtx(), &tg.MessagesSendEncryptedFileRequest{
Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, RandomID: 2, Data: tooLong,
File: &tg.InputEncryptedFileUploaded{ID: 888, Parts: 1, KeyFingerprint: 9},
})
if !tgerr.Is(err, "DATA_TOO_LONG") {
t.Fatalf("sendEncryptedFile oversized err = %v", err)
}
}

View file

@ -2,13 +2,17 @@ package rpc
import (
"context"
"fmt"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const encryptedDifferencePageSize = 1000
// 私聊密聊 qts 消息收发 RPC handlerP1。服务端是盲中继sendEncrypted* 的 bytes 是
// 客户端加密的 DecryptedMessage服务端盲存进【接收方设备】的 qts 队列、原样转发,
// 永不解密。在线推 updateNewEncryptedMessage设备定向离线靠 getDifference 补回)。
@ -34,11 +38,16 @@ func (r *Router) pushEncryptedNewMessage(ctx context.Context, msg domain.SecretC
_, _ = targeted.PushToUserAuthKey(ctx, msg.ReceiverUserID, deviceAuthKeyBytes(msg.ReceiverAuthKeyID), proto.MessageFromServer, upd)
return
}
// 回退(测试替身/未装配定向能力):账号级推送。
r.pushUserMessage(ctx, msg.ReceiverUserID, "secret chat message", upd)
// 设备隔离是安全边界;缺少定向能力时只保留 durable qts离线 difference 补回。
r.log.Error("secret chat targeted session binder unavailable",
zap.Int64("target_user_id", msg.ReceiverUserID),
zap.Int64("target_auth_key_id", msg.ReceiverAuthKeyID))
}
func (r *Router) sendEncryptedCommon(ctx context.Context, peer tg.InputEncryptedChat, randomID int64, data []byte, isService bool) (tg.MessagesSentEncryptedMessageClass, error) {
if len(data) > domain.MaxSecretMessageDataBytes {
return nil, dataTooLongErr()
}
if r.deps.SecretChats == nil {
return nil, notImplementedErr()
}
@ -46,7 +55,11 @@ func (r *Router) sendEncryptedCommon(ctx context.Context, peer tg.InputEncrypted
if err != nil {
return nil, err
}
_, stored, err := r.deps.SecretChats.SendEncrypted(ctx, peer.ChatID, userID, peer.AccessHash, domain.SecretMessageDelivery{
deviceAuthKeyID, ok := businessAuthKeyIDFrom(ctx)
if !ok {
return nil, internalErr()
}
_, stored, err := r.deps.SecretChats.SendEncrypted(ctx, peer.ChatID, userID, deviceAuthKeyID, peer.AccessHash, domain.SecretMessageDelivery{
RandomID: randomID,
Bytes: data,
IsService: isService,
@ -130,27 +143,38 @@ func (r *Router) deviceEncryptedQts(ctx context.Context) int {
return qts
}
// encryptedDifference 返回当前设备 qts > sinceQts 的加密消息TL 投影)与推进后的 qts
// getDifference 注入用)。无新消息时返回 (nil, sinceQts)
func (r *Router) encryptedDifference(ctx context.Context, sinceQts int) ([]tg.EncryptedMessageClass, int) {
// encryptedDifference 返回当前设备 qts > sinceQts 的连续前缀、推进后的 qts 与是否还有
// 下一页。存储错误或 qts gap 必须 fail-fast禁止越过缺口推进客户端水位
func (r *Router) encryptedDifference(ctx context.Context, sinceQts int) ([]tg.EncryptedMessageClass, int, bool, error) {
if r.deps.SecretChats == nil {
return nil, sinceQts
return nil, sinceQts, false, nil
}
deviceKey, ok := businessAuthKeyIDFrom(ctx)
if !ok {
return nil, sinceQts
return nil, sinceQts, false, nil
}
msgs, err := r.deps.SecretChats.ListNewMessages(ctx, deviceKey, sinceQts, 0)
if err != nil || len(msgs) == 0 {
return nil, sinceQts
msgs, err := r.deps.SecretChats.ListNewMessages(ctx, deviceKey, sinceQts, encryptedDifferencePageSize+1)
if err != nil {
return nil, sinceQts, false, err
}
if len(msgs) == 0 {
return nil, sinceQts, false, nil
}
partial := len(msgs) > encryptedDifferencePageSize
if partial {
msgs = msgs[:encryptedDifferencePageSize]
}
out := make([]tg.EncryptedMessageClass, 0, len(msgs))
newQts := sinceQts
for _, m := range msgs {
for i, m := range msgs {
expected := newQts + 1
if m.Qts != expected {
return nil, sinceQts, false, fmt.Errorf("secret chat qts gap at index %d: got %d want %d", i, m.Qts, expected)
}
out = append(out, tgEncryptedMessage(m))
newQts = m.Qts
}
return out, newQts
return out, newQts, partial, nil
}
// injectEncryptedMessages 把加密消息与推进后的 qts 注入差分响应(按类型分别写 State /
@ -169,19 +193,28 @@ func injectEncryptedMessages(diff tg.UpdatesDifferenceClass, encMsgs []tg.Encryp
// encryptedStateUpdates 返回当前设备未投递的握手/已读状态事件重建出的 update
// OtherUpdates、涉及的 peer user id补 Users、以及要登记已投递的事件 id。
// encryption 事件按 secret_chats 权威态重建(不固化快照)。
func (r *Router) encryptedStateUpdates(ctx context.Context, userID int64) (updates []tg.UpdateClass, peerUserIDs []int64, eventIDs []int64) {
// encryption 事件按 secret_chats 权威态重建(不固化密钥材料快照)。账号级邀请在
// accept 后只对未绑定设备投影为 discarded获胜设备消费并跳过绝不能收到 normal 泄漏。
func (r *Router) encryptedStateUpdates(ctx context.Context, userID int64) (updates []tg.UpdateClass, peerUserIDs []int64, eventIDs []int64, partial bool, err error) {
if r.deps.SecretChats == nil {
return nil, nil, nil
return nil, nil, nil, false, nil
}
deviceKey, ok := businessAuthKeyIDFrom(ctx)
if !ok {
return nil, nil, nil
return nil, nil, nil, false, nil
}
events, err := r.deps.SecretChats.ListStateEvents(ctx, userID, deviceKey, 0)
if err != nil || len(events) == 0 {
return nil, nil, nil
events, err := r.deps.SecretChats.ListStateEvents(ctx, userID, deviceKey, encryptedDifferencePageSize+1)
if err != nil {
return nil, nil, nil, false, err
}
if len(events) == 0 {
return nil, nil, nil, false, nil
}
partial = len(events) > encryptedDifferencePageSize
if partial {
events = events[:encryptedDifferencePageSize]
}
seenEncryption := make(map[int]struct{})
for _, ev := range events {
switch ev.Type {
case domain.EncryptedStateEventEncryption:
@ -189,12 +222,23 @@ func (r *Router) encryptedStateUpdates(ctx context.Context, userID int64) (updat
if gerr != nil || !found {
continue
}
updates = append(updates, &tg.UpdateEncryption{
Chat: tgEncryptedChatForViewer(chat, userID),
Date: ev.Date,
})
peerUserIDs = append(peerUserIDs, chat.AdminUserID, chat.ParticipantUserID)
eventIDs = append(eventIDs, ev.ID)
if _, duplicate := seenEncryption[chat.ID]; duplicate {
continue
}
seenEncryption[chat.ID] = struct{}{}
chatView := tgEncryptedChatForViewer(chat, userID)
if ev.TargetAuthKeyID == 0 && chat.State == domain.SecretChatStateNormal {
// 账号级事件只承载 accept 前邀请。accept 后获胜设备已有同步响应;其它设备
// 必须收敛为 discarded不能用当前 normal 权威态泄漏 access_hash/g_a。
if chat.AuthKeyOf(userID) == deviceKey {
continue
}
chatView = &tg.EncryptedChatDiscarded{ID: chat.ID, HistoryDeleted: true}
}
updates = append(updates, &tg.UpdateEncryption{Chat: chatView, Date: ev.Date})
peerUserIDs = append(peerUserIDs, chat.AdminUserID, chat.ParticipantUserID)
case domain.EncryptedStateEventRead:
updates = append(updates, &tg.UpdateEncryptedMessagesRead{
ChatID: ev.ChatID,
@ -204,7 +248,7 @@ func (r *Router) encryptedStateUpdates(ctx context.Context, userID int64) (updat
eventIDs = append(eventIDs, ev.ID)
}
}
return updates, peerUserIDs, eventIDs
return updates, peerUserIDs, eventIDs, partial, nil
}
// injectEncryptedOtherUpdates 把握手/已读 update 追加进差分的 OtherUpdates、把 peer
@ -214,6 +258,24 @@ func (r *Router) injectEncryptedOtherUpdates(ctx context.Context, viewerUserID i
return diff
}
users := r.tgUsersForIDs(ctx, viewerUserID, peerUserIDs)
return appendEncryptedOtherUpdates(diff, updates, users)
}
func (r *Router) injectEncryptedOtherUpdatesStrict(ctx context.Context, viewerUserID int64, diff tg.UpdatesDifferenceClass, updates []tg.UpdateClass, peerUserIDs []int64, cache *viewerPeerCache) (tg.UpdatesDifferenceClass, error) {
if len(updates) == 0 {
return diff, nil
}
if cache == nil {
cache = newViewerPeerCache(r)
}
users, err := cache.usersForIDsStrict(ctx, viewerUserID, peerUserIDs)
if err != nil {
return nil, err
}
return appendEncryptedOtherUpdates(diff, updates, r.tgUsersForViewer(viewerUserID, users)), nil
}
func appendEncryptedOtherUpdates(diff tg.UpdatesDifferenceClass, updates []tg.UpdateClass, users []tg.UserClass) tg.UpdatesDifferenceClass {
switch v := diff.(type) {
case *tg.UpdatesDifference:
v.OtherUpdates = append(v.OtherUpdates, updates...)

View file

@ -169,6 +169,52 @@ func TestSendEncryptedRPCFlow(t *testing.T) {
}
}
func TestSecretChatRejectsUnboundAccountDeviceMutations(t *testing.T) {
f := newEncryptedFixture(t)
chatID, participantAccessHash := f.acceptChat(t)
peer := tg.InputEncryptedChat{ChatID: chatID, AccessHash: participantAccessHash}
ctx := f.participantOtherCtx()
if _, err := f.router.onMessagesSendEncrypted(ctx, &tg.MessagesSendEncryptedRequest{
Peer: peer, RandomID: 8101, Data: []byte{1},
}); err == nil {
t.Fatal("unbound sendEncrypted succeeded")
} else {
assertPhoneRPCErr(t, err, "CHAT_ID_INVALID")
}
if _, err := f.router.onMessagesReadEncryptedHistory(ctx, &tg.MessagesReadEncryptedHistoryRequest{
Peer: peer, MaxDate: int(f.router.clock.Now().Unix()),
}); err == nil {
t.Fatal("unbound readEncryptedHistory succeeded")
} else {
assertPhoneRPCErr(t, err, "CHAT_ID_INVALID")
}
if _, err := f.router.onMessagesSetEncryptedTyping(ctx, &tg.MessagesSetEncryptedTypingRequest{
Peer: peer, Typing: true,
}); err == nil {
t.Fatal("unbound setEncryptedTyping succeeded")
} else {
assertPhoneRPCErr(t, err, "CHAT_ID_INVALID")
}
if _, err := f.router.onMessagesUploadEncryptedFile(ctx, &tg.MessagesUploadEncryptedFileRequest{
Peer: peer, File: &tg.InputEncryptedFileUploaded{ID: 991, Parts: 1, KeyFingerprint: 7},
}); err == nil {
t.Fatal("unbound uploadEncryptedFile succeeded")
} else {
assertPhoneRPCErr(t, err, "CHAT_ID_INVALID")
}
if _, err := f.router.onMessagesDiscardEncryption(ctx, &tg.MessagesDiscardEncryptionRequest{ChatID: chatID}); err == nil {
t.Fatal("unbound discardEncryption succeeded")
} else {
assertPhoneRPCErr(t, err, "CHAT_ID_INVALID")
}
chat, ok, err := f.store.GetSecretChat(f.ctx, chatID)
if err != nil || !ok || chat.State != domain.SecretChatStateNormal {
t.Fatalf("chat after rejected mutations = %+v ok=%v err=%v", chat, ok, err)
}
}
func encOtherUpdate[T tg.UpdateClass](t *testing.T, diff tg.UpdatesDifferenceClass) T {
t.Helper()
full, ok := diff.(*tg.UpdatesDifference)
@ -231,6 +277,131 @@ func TestEncryptionStateEventOfflineDelivery(t *testing.T) {
}
}
func TestAcceptConvergesLosingAndFutureParticipantDevices(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)
// 未绑定 participant 设备只能看到 history-deleting discarded不能拿到 normal/access_hash。
loserCtx := postresponse.WithCallbacks(f.participantOtherCtx())
diff, err := f.router.onUpdatesGetDifference(loserCtx, &tg.UpdatesGetDifferenceRequest{})
if err != nil {
t.Fatalf("loser difference: %v", err)
}
loserUpdate := encOtherUpdate[*tg.UpdateEncryption](t, diff)
loserDiscarded, ok := loserUpdate.Chat.(*tg.EncryptedChatDiscarded)
if !ok || loserDiscarded.ID != chatID || !loserDiscarded.HistoryDeleted {
t.Fatalf("loser update = %+v, want history-deleting discarded", loserUpdate.Chat)
}
postresponse.Run(loserCtx)
// 获胜设备已经从 accept 同步响应获得 normal账号级邀请事件仅确认、不回放。
winnerCtx := postresponse.WithCallbacks(f.participantCtx())
winnerDiff, err := f.router.onUpdatesGetDifference(winnerCtx, &tg.UpdatesGetDifferenceRequest{})
if err != nil {
t.Fatalf("winner difference: %v", err)
}
if _, ok := winnerDiff.(*tg.UpdatesDifferenceEmpty); !ok {
t.Fatalf("winner difference = %T, want UpdatesDifferenceEmpty", winnerDiff)
}
postresponse.Run(winnerCtx)
for name, deviceKey := range map[string]int64{
"winner": businessAuthKeyInt64(encPartAuthKey),
"loser": businessAuthKeyInt64(encPartOtherAuthKey),
} {
pending, err := f.queue.ListUndeliveredStateEvents(f.ctx, f.participant.ID, deviceKey, 100)
if err != nil || len(pending) != 0 {
t.Fatalf("%s pending events = %+v err=%v, want none", name, pending, err)
}
}
}
func TestEncryptedDifferenceUsesSliceForQtsPagination(t *testing.T) {
f := newEncryptedFixture(t)
deviceKey := businessAuthKeyInt64(encPartAuthKey)
for i := 1; i <= encryptedDifferencePageSize+1; i++ {
if _, _, err := f.queue.AppendEncryptedMessage(f.ctx, domain.SecretChatMessage{
ReceiverAuthKeyID: deviceKey,
ReceiverUserID: f.participant.ID,
ChatID: 700,
RandomID: int64(70000 + i),
Date: 1700000000 + i,
Bytes: []byte{byte(i)},
}); err != nil {
t.Fatalf("append encrypted message %d: %v", i, err)
}
}
first, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{})
if err != nil {
t.Fatalf("first difference: %v", err)
}
slice, ok := first.(*tg.UpdatesDifferenceSlice)
if !ok {
t.Fatalf("first difference = %T, want UpdatesDifferenceSlice", first)
}
if len(slice.NewEncryptedMessages) != encryptedDifferencePageSize || slice.IntermediateState.Qts != encryptedDifferencePageSize {
t.Fatalf("first encrypted page len/qts = %d/%d, want %d/%d",
len(slice.NewEncryptedMessages), slice.IntermediateState.Qts, encryptedDifferencePageSize, encryptedDifferencePageSize)
}
second, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{Qts: slice.IntermediateState.Qts})
if err != nil {
t.Fatalf("second difference: %v", err)
}
full, ok := second.(*tg.UpdatesDifference)
if !ok {
t.Fatalf("second difference = %T, want UpdatesDifference", second)
}
if len(full.NewEncryptedMessages) != 1 || full.State.Qts != encryptedDifferencePageSize+1 {
t.Fatalf("second encrypted page len/qts = %d/%d, want 1/%d",
len(full.NewEncryptedMessages), full.State.Qts, encryptedDifferencePageSize+1)
}
}
func TestEncryptedDifferenceUsesSliceForStateEventPagination(t *testing.T) {
f := newEncryptedFixture(t)
deviceKey := businessAuthKeyInt64(encPartAuthKey)
for i := 1; i <= encryptedDifferencePageSize+1; i++ {
if _, err := f.queue.AppendStateEvent(f.ctx, domain.EncryptedStateEvent{
TargetUserID: f.participant.ID,
TargetAuthKeyID: deviceKey,
ChatID: 701,
Type: domain.EncryptedStateEventRead,
MaxDate: 1700000000 + i,
Date: 1700001000 + i,
}); err != nil {
t.Fatalf("append state event %d: %v", i, err)
}
}
firstCtx := postresponse.WithCallbacks(f.participantCtx())
first, err := f.router.onUpdatesGetDifference(firstCtx, &tg.UpdatesGetDifferenceRequest{})
if err != nil {
t.Fatalf("first difference: %v", err)
}
slice, ok := first.(*tg.UpdatesDifferenceSlice)
if !ok || len(slice.OtherUpdates) != encryptedDifferencePageSize {
t.Fatalf("first state page = %T updates=%d, want slice/%d", first, len(slice.OtherUpdates), encryptedDifferencePageSize)
}
postresponse.Run(firstCtx)
secondCtx := postresponse.WithCallbacks(f.participantCtx())
second, err := f.router.onUpdatesGetDifference(secondCtx, &tg.UpdatesGetDifferenceRequest{})
if err != nil {
t.Fatalf("second difference: %v", err)
}
full, ok := second.(*tg.UpdatesDifference)
if !ok || len(full.OtherUpdates) != 1 {
t.Fatalf("second state page = %T updates=%d, want full/1", second, len(full.OtherUpdates))
}
postresponse.Run(secondCtx)
if pending, err := f.queue.ListUndeliveredStateEvents(f.ctx, f.participant.ID, deviceKey, 10); err != nil || len(pending) != 0 {
t.Fatalf("pending after two pages = %+v err=%v, want none", pending, err)
}
}
func TestEncryptedDifferenceAcknowledgesOnlyProjectedStateEvents(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)

View file

@ -14,14 +14,16 @@ import (
// P1在线推送已读 durable 离线补偿见后续 encrypted_state_events。typing 是 transient。
// 设计见 docs/secret-chat-module.md §8。
// resolveSecretChatPeer 校验调用方是密聊参与者且 access_hash 匹配,返回密聊、对端 user
// 对端绑定设备 auth_key。失败返回 CHAT_ID_INVALID。
// resolveSecretChatPeer 校验调用方是 normal 密聊的绑定设备且 access_hash 匹配,返回密聊
// 对端 user、对端绑定设备 auth_key。失败返回 CHAT_ID_INVALID。
func (r *Router) resolveSecretChatPeer(ctx context.Context, userID int64, peer tg.InputEncryptedChat) (domain.SecretChat, int64, int64, error) {
chat, ok, err := r.deps.SecretChats.GetSecretChat(ctx, peer.ChatID)
if err != nil {
return domain.SecretChat{}, 0, 0, internalErr()
}
if !ok || !chat.HasParticipant(userID) || chat.AccessHashFor(userID) != peer.AccessHash {
deviceAuthKeyID, hasDevice := businessAuthKeyIDFrom(ctx)
if !ok || !hasDevice || chat.State != domain.SecretChatStateNormal || !chat.HasParticipant(userID) ||
chat.AuthKeyOf(userID) != deviceAuthKeyID || chat.AccessHashFor(userID) != peer.AccessHash {
return domain.SecretChat{}, 0, 0, chatIDInvalidErr()
}
return chat, chat.PeerOf(userID), chat.PeerAuthKeyOf(userID), nil
@ -49,11 +51,10 @@ func (r *Router) pushEncryptedPeerUpdate(ctx context.Context, peerUserID, peerAu
}
return
}
if transient {
r.pushUserMessageTransient(ctx, peerUserID, logMessage, upd)
} else {
r.pushUserMessage(ctx, peerUserID, logMessage, upd)
}
r.log.Error("secret chat targeted session binder unavailable",
zap.String("update", logMessage),
zap.Int64("target_user_id", peerUserID),
zap.Int64("target_auth_key_id", peerAuthKeyID))
}
func (r *Router) onMessagesReadEncryptedHistory(ctx context.Context, req *tg.MessagesReadEncryptedHistoryRequest) (bool, error) {

View file

@ -27,10 +27,28 @@ func (r *Router) registerEphemeral(d *tlprofile.Dispatcher) {
registerRPC[*tg.EphemeralGetCallbackAnswerRequest](d, tlprofile.SemanticMethodEphemeralGetCallbackAnswer, func(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (any, error) {
return r.onEphemeralGetCallbackAnswer(ctx, request)
})
registerRPC[*tg.EphemeralEditMessageRequest](d, tlprofile.SemanticMethodEphemeralEditMessage, func(ctx context.Context, request *tg.EphemeralEditMessageRequest) (any, error) {
return r.onEphemeralEditWelcomeMessage(ctx, request)
})
registerRPC[*tg.EphemeralDeleteWelcomeMessageRequest](d, tlprofile.SemanticMethodEphemeralDeleteWelcomeMessage, func(ctx context.Context, request *tg.EphemeralDeleteWelcomeMessageRequest) (any, error) {
return r.onEphemeralDeleteWelcomeMessage(ctx, request)
})
registerRPC[*tg.EphemeralDeleteAllWelcomeMessagesRequest](d, tlprofile.SemanticMethodEphemeralDeleteAllWelcomeMessages, func(ctx context.Context, request *tg.EphemeralDeleteAllWelcomeMessagesRequest) (any, error) {
return r.onEphemeralDeleteAllWelcomeMessages(ctx, request)
})
registerRPC[*tg.EphemeralGetWelcomeMessagesRequest](d, tlprofile.SemanticMethodEphemeralGetWelcomeMessages, func(ctx context.Context, request *tg.EphemeralGetWelcomeMessagesRequest) (any, error) {
return r.onEphemeralGetWelcomeMessages(ctx, request)
})
}
func (r *Router) onEphemeralSendMessage(ctx context.Context, request *tg.EphemeralSendMessageRequest) (tg.UpdatesClass, error) {
if request == nil || r.deps.Ephemeral == nil {
if request == nil {
return nil, inputRequestInvalidErr()
}
if request.Welcome {
return r.onEphemeralSendWelcomeMessage(ctx, request)
}
if r.deps.Ephemeral == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)

View file

@ -6,6 +6,7 @@ import (
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap"
"telesrv/internal/store"
@ -61,11 +62,12 @@ func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.Ephe
if online, ok := r.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(event.TargetUserID) {
return
}
binder, ok := r.deps.Sessions.(ExactLayerTransientSessionBinder)
binder, ok := r.deps.Sessions.(SemanticTransientSessionBinder)
if !ok {
return
}
var updates tg.UpdatesClass
var semantic tlprofile.SemanticID
switch event.Kind {
case store.EphemeralPushNew, store.EphemeralPushEdit:
if event.TargetUserID != event.Message.ReceiverUserID || event.Message.Deleted {
@ -76,11 +78,16 @@ func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.Ephe
return
}
updates = built
semantic = tlprofile.SemanticTypeUpdateNewEphemeralMessage
if event.Kind == store.EphemeralPushEdit {
semantic = tlprofile.SemanticTypeUpdateEditEphemeralMessage
}
case store.EphemeralPushDelete:
if !event.Message.Deleted || (event.TargetUserID != event.Message.SenderUserID && event.TargetUserID != event.Message.ReceiverUserID) {
return
}
updates = ephemeralDeleteUpdates(event.Message, event.Date)
semantic = tlprofile.SemanticTypeUpdateDeleteEphemeralMessages
case store.EphemeralPushCallback:
callback := event.Callback
if callback == nil || callback.BotUserID != event.TargetUserID || callback.MessageID != event.Message.ID || callback.Peer != event.Message.Peer {
@ -92,16 +99,13 @@ func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.Ephe
}
update.SetData(callback.Data)
updates = &tg.Updates{Updates: []tg.UpdateClass{update}, Date: event.Date}
semantic = tlprofile.SemanticTypeUpdateBotCallbackQuery
default:
return
}
minLayer := 228
if event.Kind == store.EphemeralPushCallback {
minLayer = 225
}
if event.TargetBusinessAuthKey != ([8]byte{}) {
_, _ = binder.PushToUserAuthKeyTransientAtLeastLayer(ctx, event.TargetUserID, event.TargetBusinessAuthKey, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
_, _ = binder.PushToUserAuthKeyTransientCompatible(ctx, event.TargetUserID, event.TargetBusinessAuthKey, semantic, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
return
}
_, _ = binder.PushToUserTransientAtLeastLayer(ctx, event.TargetUserID, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
_, _ = binder.PushToUserTransientCompatible(ctx, event.TargetUserID, semantic, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout)
}

View file

@ -9,6 +9,7 @@ import (
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
@ -38,23 +39,23 @@ type ephemeralPushSessions struct {
type ephemeralPushCapture struct {
userID int64
authKey [8]byte
minLayer int
semantic tlprofile.SemanticID
message tg.UpdatesClass
}
func (s *ephemeralPushSessions) IsUserOnline(int64) bool { return s.online }
func (s *ephemeralPushSessions) PushToUserTransientAtLeastLayer(_ context.Context, userID int64, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
func (s *ephemeralPushSessions) PushToUserTransientCompatible(_ context.Context, userID int64, semantic tlprofile.SemanticID, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.broadcasts = append(s.broadcasts, ephemeralPushCapture{userID: userID, minLayer: minLayer, message: message})
s.broadcasts = append(s.broadcasts, ephemeralPushCapture{userID: userID, semantic: semantic, message: message})
return 1, nil
}
func (s *ephemeralPushSessions) PushToUserAuthKeyTransientAtLeastLayer(_ context.Context, userID int64, authKey [8]byte, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
func (s *ephemeralPushSessions) PushToUserAuthKeyTransientCompatible(_ context.Context, userID int64, authKey [8]byte, semantic tlprofile.SemanticID, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.targeted = append(s.targeted, ephemeralPushCapture{userID: userID, authKey: authKey, minLayer: minLayer, message: message})
s.targeted = append(s.targeted, ephemeralPushCapture{userID: userID, authKey: authKey, semantic: semantic, message: message})
return 1, nil
}
@ -135,8 +136,8 @@ func TestEphemeralPushMultiInstanceSourceDedupAndLayerRouting(t *testing.T) {
if broadcast, targeted := sessions2.counts(); broadcast != 1 || targeted != 0 {
t.Fatalf("remote delivery broadcast=%d targeted=%d", broadcast, targeted)
}
if sessions1.broadcasts[0].minLayer != 228 || sessions2.broadcasts[0].minLayer != 228 {
t.Fatalf("min layers source=%d remote=%d", sessions1.broadcasts[0].minLayer, sessions2.broadcasts[0].minLayer)
if sessions1.broadcasts[0].semantic != tlprofile.SemanticTypeUpdateNewEphemeralMessage || sessions2.broadcasts[0].semantic != tlprofile.SemanticTypeUpdateNewEphemeralMessage {
t.Fatalf("semantics source=%#x remote=%#x", sessions1.broadcasts[0].semantic, sessions2.broadcasts[0].semantic)
}
if len(broker.published) != 1 || broker.published[0].SourceID != "one" {
t.Fatalf("published=%+v", broker.published)
@ -151,7 +152,7 @@ func TestEphemeralPushMultiInstanceSourceDedupAndLayerRouting(t *testing.T) {
TargetBusinessAuthKey: key, Message: message, Date: int(time.Now().Unix()),
})
_, targeted := sessions2.counts()
if targeted != 1 || sessions2.targeted[0].authKey != key || sessions2.targeted[0].minLayer != 228 {
if targeted != 1 || sessions2.targeted[0].authKey != key || sessions2.targeted[0].semantic != tlprofile.SemanticTypeUpdateDeleteEphemeralMessages {
t.Fatalf("targeted=%+v", sessions2.targeted)
}
deletedUpdates, ok := sessions2.targeted[0].message.(*tg.Updates)

View file

@ -309,6 +309,7 @@ func requestMsgExpiredErr() error { return tgerr.New(400, "REQUEST_MSG_EXPIRED")
func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVALID") }
func inputRequestTooLongErr() error { return tgerr.New(400, "INPUT_REQUEST_TOO_LONG") }
func dataTooLongErr() error { return tgerr.New(400, "DATA_TOO_LONG") }
func inputTextEmptyErr() error { return tgerr.New(400, "INPUT_TEXT_EMPTY") }
func inputTextTooLongErr() error { return tgerr.New(400, "INPUT_TEXT_TOO_LONG") }
@ -329,10 +330,16 @@ func topicsEmptyErr() error { return tgerr.New(400, "TOPICS_EMPTY") }
// randomIDEmptyErr 表示发送消息缺少 random_id。
func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") }
func randomIDExpiredErr() error { return tgerr.New(400, "RANDOM_ID_EXPIRED") }
// randomIDDuplicateErr 表示同一发送者重复使用 random_id但请求载荷与首次
// 成功发送不一致。Layer 227 为该错误定义的 code 是 500。
func randomIDDuplicateErr() error { return tgerr.New(500, "RANDOM_ID_DUPLICATE") }
// secretChatRandomIDDuplicateErr 使用 Telegram messages.requestEncryption 的官方
// BAD_REQUEST 语义;普通消息历史上使用的 500 映射保持独立,避免扩大改动范围。
func secretChatRandomIDDuplicateErr() error { return tgerr.New(400, "RANDOM_ID_DUPLICATE") }
// scheduleDateInvalidErr 表示当前阶段不支持定时消息。
func scheduleDateInvalidErr() error { return tgerr.New(400, "SCHEDULE_DATE_INVALID") }

View file

@ -232,6 +232,7 @@ func (r *Router) deactivateAllRegistryUsernames(ctx context.Context, peer domain
// invalidateRegistryProjection drops the cached user/channel projections that
// embed the username vector, so the next getFullUser / getFullChannel rebuilds it.
func (r *Router) invalidateRegistryProjection(peer domain.Peer) {
r.InvalidatePeerIdentityReadModel(peer)
switch peer.Type {
case domain.PeerTypeUser:
r.invalidateRPCProjectionForUser(peer.ID)
@ -317,7 +318,7 @@ func appendUsernameProjectionPeers(peers []domain.Peer, seen map[domain.Peer]str
peers = append(peers, peer)
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil {
if u, ok := item.(*tg.User); ok && u != nil && !u.Deleted {
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
}
}
@ -338,7 +339,7 @@ func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPe
}
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil {
if !ok || u == nil || u.Deleted {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
@ -383,16 +384,24 @@ func (r *Router) usernameRegistryMap(ctx context.Context, peers []domain.Peer) m
if r.deps.Usernames == nil || len(peers) == 0 {
return nil
}
usernames, _ := r.peerIdentityMaps(ctx, peers, true, false)
return usernames
}
func (r *Router) loadUsernameRegistryMap(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
if len(peers) == 1 {
list, err := r.deps.Usernames.PeerUsernames(ctx, peers[0])
if err != nil || len(list) == 0 {
return nil
if err != nil {
return nil, err
}
return map[domain.Peer][]domain.Username{peers[0]: list}
if len(list) == 0 {
return map[domain.Peer][]domain.Username{}, nil
}
return map[domain.Peer][]domain.Username{peers[0]: list}, nil
}
byPeer, err := r.deps.Usernames.UsernamesBatch(ctx, peers)
if err != nil {
return nil
return nil, err
}
return byPeer
return byPeer, nil
}

View file

@ -9,7 +9,6 @@ import (
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/branding"
androidcompat "telesrv/internal/compat/android"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
)
@ -32,14 +31,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
return r.onHelpSaveAppLog(ctx)
})
registerRPC[*tg.HelpGetAppUpdateRequest](d, tlprofile.SemanticMethodHelpGetAppUpdate, func(ctx context.Context, layerRequest *tg.HelpGetAppUpdateRequest) (any, error) {
source := layerRequest.
Source
_ = source
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return ioscompat.NoAppUpdate(), nil
return r.onHelpGetAppUpdate(ctx, layerRequest.Source)
})
registerRPC[*tg.HelpGetAppConfigRequest](d, tlprofile.SemanticMethodHelpGetAppConfig, func(ctx context.Context, layerRequest *tg.HelpGetAppConfigRequest) (any, error) {
hash := layerRequest.
@ -137,7 +129,7 @@ func (r *Router) onHelpSaveAppLog(ctx context.Context) (bool, error) {
}
func (r *Router) onHelpGetConfig(ctx context.Context) (*tg.Config, error) {
config := tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL)
config := tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL, r.cfg.UpdatePublicURL)
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()

View file

@ -0,0 +1,99 @@
package rpc
import (
"context"
"strings"
"go.uber.org/zap"
"github.com/iamxvbaba/td/tg"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/updatecdn"
)
func (r *Router) onHelpGetAppUpdate(ctx context.Context, source string) (tg.HelpAppUpdateClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
if r.deps.AppUpdates == nil {
return ioscompat.NoAppUpdate(), nil
}
info, ok := ClientInfoFrom(ctx)
if !ok {
return ioscompat.NoAppUpdate(), nil
}
platform := updatePlatform(info.ClientType())
if platform == "" {
return ioscompat.NoAppUpdate(), nil
}
langCode := strings.TrimSpace(info.LangCode)
if langCode == "" {
langCode = strings.TrimSpace(info.SystemLangCode)
}
resolved, err := r.deps.AppUpdates.Resolve(ctx, updatecdn.ResolveRequest{
Platform: platform,
Channel: updateChannel(info),
Version: info.AppVersion,
Source: boundedUpdateSource(source),
LangCode: langCode,
})
if err != nil {
// Update discovery is advisory. Returning a bounded no-update response
// avoids turning a temporary CDN outage into a client-visible RPC 500.
r.log.Warn("application update resolve failed",
zap.String("platform", platform),
zap.String("app_version", info.AppVersion),
zap.Error(err))
return ioscompat.NoAppUpdate(), nil
}
if resolved == nil {
return ioscompat.NoAppUpdate(), nil
}
result := &tg.HelpAppUpdate{
ID: resolved.ID,
Version: resolved.Version,
Text: resolved.Text,
Entities: []tg.MessageEntityClass{},
}
result.SetCanNotSkip(resolved.CanNotSkip)
if resolved.URL != "" {
result.SetURL(resolved.URL)
}
return result, nil
}
func updateChannel(info ClientInfo) string {
version := strings.ToLower(info.AppVersion)
switch {
case strings.Contains(version, "alpha"):
return "alpha"
case strings.Contains(version, "beta"):
return "beta"
default:
return "stable"
}
}
func boundedUpdateSource(source string) string {
source = strings.TrimSpace(source)
if len(source) > 256 {
return source[:256]
}
return source
}
func updatePlatform(clientType ClientType) string {
switch clientType {
case ClientTypeAndroid:
return "android"
case ClientTypeIOS:
return "ios"
case ClientTypeMacOS:
return "macos"
case ClientTypeTDesktop:
return "tdesktop"
default:
return ""
}
}

View file

@ -0,0 +1,66 @@
package rpc
import (
"context"
"errors"
"testing"
"go.uber.org/zap"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/updatecdn"
)
type fakeAppUpdateResolver struct {
request updatecdn.ResolveRequest
result *updatecdn.ResolvedUpdate
err error
}
func (f *fakeAppUpdateResolver) Resolve(_ context.Context, req updatecdn.ResolveRequest) (*updatecdn.ResolvedUpdate, error) {
f.request = req
return f.result, f.err
}
func TestHelpGetAppUpdateUsesClientPlatformVersionSourceAndLanguage(t *testing.T) {
resolver := &fakeAppUpdateResolver{result: &updatecdn.ResolvedUpdate{
ID: 91, Version: "12.9.1", Text: "Новая версия", URL: "https://updates.example/app.apk", CanNotSkip: true,
}}
router := New(Config{PublicBaseURL: "https://telesrv.example"}, Deps{AppUpdates: resolver}, zap.NewNop(), clock.System)
ctx := WithClientInfo(WithUserID(context.Background(), 1000000001), ClientInfo{
Type: ClientTypeAndroid, AppVersion: "12.9.0 (500)", LangCode: "ru", SystemLangCode: "en",
})
result, err := router.onHelpGetAppUpdate(ctx, "com.example.store")
if err != nil {
t.Fatal(err)
}
update, ok := result.(*tg.HelpAppUpdate)
if !ok {
t.Fatalf("result = %T, want *tg.HelpAppUpdate", result)
}
if update.ID != 91 || update.Version != "12.9.1" || update.Text != "Новая версия" || !update.GetCanNotSkip() {
t.Fatalf("update = %#v", update)
}
if got, ok := update.GetURL(); !ok || got != "https://updates.example/app.apk" {
t.Fatalf("url = %q, %v", got, ok)
}
if resolver.request.Platform != "android" || resolver.request.Version != "12.9.0 (500)" ||
resolver.request.Source != "com.example.store" || resolver.request.LangCode != "ru" {
t.Fatalf("resolve request = %#v", resolver.request)
}
}
func TestHelpGetAppUpdateFailsClosedWithoutRPCError(t *testing.T) {
resolver := &fakeAppUpdateResolver{err: errors.New("service unavailable")}
router := New(Config{PublicBaseURL: "https://telesrv.example"}, Deps{AppUpdates: resolver}, zap.NewNop(), clock.System)
ctx := WithClientInfo(WithUserID(context.Background(), 1000000001), ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.0"})
result, err := router.onHelpGetAppUpdate(ctx, "")
if err != nil {
t.Fatal(err)
}
if _, ok := result.(*tg.HelpNoAppUpdate); !ok {
t.Fatalf("result = %T, want *tg.HelpNoAppUpdate", result)
}
}

View file

@ -173,6 +173,7 @@ func (r *Router) PrepareAdmittedReplay(
restoreErr = fmt.Errorf("prepare delivered exact RPC replay metadata: %w", prepareErr)
return
}
defer updatesDelivery.releaseSessionActivation()
replayCtx = r.applyLayerRPCWrapperEffects(replayCtx, profile, profileKnown, identity, effects, msgID, admissionSeq, layerRPCWrapperApplyReplayRestore)
if profileKnown && layerRPCProfileEvidenceFresh(replayCtx) {
r.maybeMarkSessionReceivesUpdates(replayCtx)
@ -184,7 +185,9 @@ func (r *Router) PrepareAdmittedReplay(
// base for ordinary post-response work. Replay restoration is a
// stricter ordered barrier, so every phase shares the overall deadline.
updatesDelivery.baseCtx = replayCtx
r.runUpdatesDeliveryPlan(updatesDelivery.snapshot())
snapshot := updatesDelivery.snapshot()
updatesDelivery.disownSessionActivation()
r.runUpdatesDeliveryPlan(snapshot)
}
if err := replayCtx.Err(); err != nil {
restoreErr = fmt.Errorf("restore delivered exact RPC replay metadata: %w", err)
@ -236,6 +239,7 @@ func (r *Router) DispatchAdmitted(
if err != nil {
return nil, method, err
}
defer updatesDelivery.releaseSessionActivation()
ctx, err = r.applyLayerRPCWrappers(ctx, msgID, admissionSeq, request)
if err != nil {
return nil, method, err
@ -274,7 +278,7 @@ func (r *Router) DispatchAdmitted(
}
dbBefore := dbtrace.SnapshotFromContext(ctx)
start := time.Now()
result, err := r.dispatcher.Dispatch(ctx, request)
result, err := r.dispatchGeneratedSafely(ctx, method, request)
dur := time.Since(start)
dbDelta := dbtrace.SnapshotFromContext(ctx).Sub(dbBefore)
fields := append([]zap.Field{
@ -284,10 +288,8 @@ func (r *Router) DispatchAdmitted(
zap.Duration("dur", dur),
}, r.contextLogFields(ctx)...)
fields = dbtrace.AppendZapFields(fields, "handler_", dbDelta)
if err != nil || dur > 100*time.Millisecond {
if err != nil {
fields = append(fields, zap.Error(err))
}
if err != nil {
fields = append(fields, zap.Error(err))
r.log.Info("RPC inner handled", fields...)
} else {
r.log.Debug("RPC inner handled", fields...)

View file

@ -111,7 +111,7 @@ func (r *Router) PublishAdmittedLayerProfileEvidence(
effectiveAuthKeyID := rawAuthKeyID
if r.deps.Auth != nil {
resolveCtx, cancel := context.WithTimeout(ctx, authLayerPublicationTimeout)
resolved, found, err := r.deps.Auth.ResolveAuthKey(resolveCtx, rawAuthKeyID)
resolved, found, err := r.resolveAuthKeyCached(resolveCtx, rawAuthKeyID)
cancel()
switch {
case err != nil:
@ -386,7 +386,7 @@ func (r *Router) ResolveInheritedAuthKeyLayer(ctx context.Context, rawAuthKeyID
}
effectiveAuthKeyID := rawAuthKeyID
if r.deps.Auth != nil {
resolved, found, err := r.deps.Auth.ResolveAuthKey(ctx, rawAuthKeyID)
resolved, found, err := r.resolveAuthKeyCached(ctx, rawAuthKeyID)
if err != nil {
return 0, false, wrapLayerEvidenceStoreAvailability(err)
}

View file

@ -46,7 +46,7 @@ func TestResolveInheritedAuthKeyLayerUsesAuthKeyAuthorityOnly(t *testing.T) {
}{
{name: "auth key primary", keyLayer: 225, authorization: 227, want: 225, found: true},
{name: "authorization mirror is not protocol evidence", keyLayer: 0, authorization: 225},
{name: "unsupported primary is authoritative unknown", keyLayer: 229, authorization: 227, found: true},
{name: "unsupported primary is authoritative unknown", keyLayer: 230, authorization: 227, found: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -104,6 +104,34 @@ func TestResolveInheritedAuthKeyLayerNormalizesBoundTempToPermanent(t *testing.T
}
}
func TestPositiveBindingResolutionIsSharedByLayerAndDispatchPaths(t *testing.T) {
rawAuthKeyID := [8]byte{0x31, 1}
permAuthKeyID := [8]byte{0x32, 1}
const sessionID = int64(311)
auth := &captureAuthService{
resolvedAuthKeyID: permAuthKeyID,
hasResolved: true,
authKeyClientInfos: map[[8]byte]domain.AuthKeyClientInfo{
permAuthKeyID: {Layer: 227},
},
}
sessions := &captureSessions{}
r := New(Config{DC: 2, TempKeyResolveCacheTTL: time.Minute}, Deps{
Auth: auth, Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
if layer, found, err := r.ResolveInheritedAuthKeyLayer(context.Background(), rawAuthKeyID); err != nil || !found || layer != 227 {
t.Fatalf("inherited layer = (%d,%v,%v), want (227,true,nil)", layer, found, err)
}
if got, err := r.effectiveAuthKeyID(context.Background(), rawAuthKeyID, sessionID); err != nil || got != permAuthKeyID {
t.Fatalf("effective auth key = (%x,%v), want (%x,nil)", got, err, permAuthKeyID)
}
freezeAndPublishLayer(t, r, rawAuthKeyID, sessionID, 10, 1, 227)
if auth.resolveCount != 1 {
t.Fatalf("ResolveAuthKey calls across inherited/dispatch/publication = %d, want 1", auth.resolveCount)
}
}
func TestResolveInheritedAuthKeyLayerMarksOnlyAvailabilityFailures(t *testing.T) {
boom := errors.New("postgres temporarily unavailable")
for _, tt := range []struct {
@ -254,8 +282,8 @@ func TestBindTempAuthKeyLayerPrecedenceAndRawShadow(t *testing.T) {
auth := &captureAuthService{authKeyClientInfos: map[[8]byte]domain.AuthKeyClientInfo{
permAuthKeyID: {Layer: 225},
}}
// Model the store-owned bind transaction. The router must only reload
// this merged permanent primary; it must not derive or persist a winner
// Model the store-owned bind transaction. Bind returns the exact committed
// tuple; the router must not re-read a later permanent row or derive a winner
// from its process-local exact-session registry after Bind returns.
auth.bindTempHook = func(domain.TempAuthKeyBinding) error {
auth.authKeyClientInfos[rawAuthKeyID] = domain.AuthKeyClientInfo{Layer: tt.want, LayerObservationID: 42}
@ -273,6 +301,10 @@ func TestBindTempAuthKeyLayerPrecedenceAndRawShadow(t *testing.T) {
if err != nil || !ok {
t.Fatalf("bind = (%v,%v), want (true,nil)", ok, err)
}
if auth.authKeyInfoLookups != 0 || auth.authorizationLookups != 0 {
t.Fatalf("bind re-read durable Layer state: key=%d authorization=%d",
auth.authKeyInfoLookups, auth.authorizationLookups)
}
if got := auth.authKeyClientInfos[rawAuthKeyID].Layer; got != tt.want {
t.Fatalf("raw shadow = %d, want %d", got, tt.want)
}
@ -324,7 +356,7 @@ func TestResolveInheritedBoundTempUnsupportedPermanentBlocksRawShadow(t *testing
hasResolved: true,
authKeyClientInfos: map[[8]byte]domain.AuthKeyClientInfo{
rawAuthKeyID: {Layer: 225},
permAuthKeyID: {Layer: 229},
permAuthKeyID: {Layer: 230},
},
}
r := New(Config{DC: 2}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System)
@ -347,11 +379,11 @@ func TestBindTempAuthKeyFuturePermanentClearsInheritedUntilFreshExplicit(t *test
hasResolved: true,
authKeyClientInfos: map[[8]byte]domain.AuthKeyClientInfo{
rawAuthKeyID: {Layer: 225},
permAuthKeyID: {Layer: 229, LayerObservationID: 44},
permAuthKeyID: {Layer: 230, LayerObservationID: 44},
},
}
auth.bindTempHook = func(domain.TempAuthKeyBinding) error {
auth.authKeyClientInfos[rawAuthKeyID] = domain.AuthKeyClientInfo{Layer: 229, LayerObservationID: 44}
auth.authKeyClientInfos[rawAuthKeyID] = domain.AuthKeyClientInfo{Layer: 230, LayerObservationID: 44}
return nil
}
sessions := &inheritedLayerCaptureSessions{}
@ -389,7 +421,7 @@ func TestBindTempAuthKeyFuturePermanentClearsInheritedUntilFreshExplicit(t *test
func TestSupportedExplicitEvidenceClearsUnsupportedCacheState(t *testing.T) {
authKeyID := [8]byte{0x63, 1}
auth := &captureAuthService{authKeyClientInfos: map[[8]byte]domain.AuthKeyClientInfo{
authKeyID: {Layer: 229},
authKeyID: {Layer: 230},
}}
r := New(Config{DC: 2}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System)
if layer, found, err := r.ResolveInheritedAuthKeyLayer(context.Background(), authKeyID); err != nil || !found || layer != 0 {

View file

@ -100,3 +100,44 @@ func mediaSearchCanReusePeerWideCount(req *tg.MessagesSearchRequest) bool {
}
return searchFilterNeedsMediaStore(req.Filter)
}
func (r *Router) mediaSearchRequestFromMessagesSearch(
ctx context.Context,
userID int64,
req *tg.MessagesSearchRequest,
filter domain.MessageFilter,
) (domain.MediaSearchRequest, error) {
out := domain.MediaSearchRequest{
Categories: mediaCategoriesForFilter(req.Filter),
Query: req.Q,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
SavedPeer: filter.SavedPeer,
SavedReactions: append([]domain.MessageReaction(nil), filter.SavedReactions...),
OffsetID: req.OffsetID,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: req.Limit,
MaxID: req.MaxID,
MinID: req.MinID,
}
if fromInput, present := req.GetFromID(); present {
if fromInput == nil {
return domain.MediaSearchRequest{}, peerIDInvalidErr()
}
from, err := r.checkedDomainPeerFromInputPeer(ctx, userID, fromInput)
if err != nil {
return domain.MediaSearchRequest{}, err
}
if from.Type != domain.PeerTypeUser || from.ID == 0 {
return domain.MediaSearchRequest{}, peerIDInvalidErr()
}
out.SenderUserID = from.ID
}
if topMsgID, present := req.GetTopMsgID(); present {
if topMsgID <= 0 || topMsgID > domain.MaxMessageBoxID {
return domain.MediaSearchRequest{}, msgIDInvalidErr()
}
out.TopMsgID = topMsgID
}
return out, nil
}

View file

@ -2,11 +2,10 @@ package rpc
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"telesrv/internal/links"
)
@ -30,9 +29,10 @@ import (
// app-link。客户端实体保持在前(超过上限裁剪时优先保留),结果裁剪到实体上限。
func augmentAutoEntities(message string, entities []tg.MessageEntityClass, appLinks links.AppLinkBuilder) []tg.MessageEntityClass {
// 快路径:绝大多数消息不含任何可自动识别的触发字符。单次 ContainsAny 扫描即短路返回,
// 跳过下面各检测器对全文的扫描与区间分配(纯文本发送零额外开销)。所有 http(s) 链接
// 都含 '/',故 "@#$/" 一并覆盖 url 检测;email/phone 未实现故不在触发集内。
if message == "" || !strings.ContainsAny(message, "@#$/") {
// 跳过下面各检测器对全文的扫描与区间分配(纯文本发送零额外开销)。裸域名 URL 只需
// 一个 '.' 即可触发(如 github.com进入检测后仍会做 TLD/边界校验email/phone
// 未实现故不在触发集内。
if message == "" || !strings.ContainsAny(message, "@#$/.") {
return entities
}
type interval struct{ start, end int }
@ -89,17 +89,13 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass, appLi
}
}
// 其余自动实体落在任何已占区间(客户端实体或 URL 跨度)内则丢弃;客户端实体优先保留(上限内)。
for _, c := range detectMentionEntities(message) {
accept(c)
// 其余自动实体由协议中立 detector 产生RPC 边界只负责把 domain entity 投影为 TL。
// 这样服务端生成的系统消息可复用同一 UTF-16/URL 排除规则,而 tg 类型仍不越过 RPC。
spans := make([]domain.MessageEntitySpan, 0, len(occupied))
for _, interval := range occupied {
spans = append(spans, domain.MessageEntitySpan{Offset: interval.start, Length: interval.end - interval.start})
}
for _, c := range detectHashtagEntities(message) {
accept(c)
}
for _, c := range detectCashtagEntities(message) {
accept(c)
}
for _, c := range detectBotCommandEntities(message) {
for _, c := range tgMessageEntities(domain.DetectAutomaticMessageEntities(message, spans)) {
accept(c)
}
@ -114,163 +110,3 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass, appLi
func (r *Router) augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass {
return augmentAutoEntities(message, entities, r.appLinks)
}
// isWordRune 判定「单词字符」(用于实体前导边界:前一个字符是单词字符时不是新实体起点,
// 借此排除 email 的 local@domain、路径里的 and/or 等)。
func isWordRune(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
// isHashtagRune 是 hashtag 正文允许的字符(支持 unicode 字母/数字,如 #日本語)。
func isHashtagRune(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
// prevRuneBefore 取字节位置 i 之前的一个完整 rune(供前导边界判定);i<=0 返回 ok=false
// (字符串起点视为合法实体边界)。
func prevRuneBefore(s string, i int) (rune, bool) {
if i <= 0 || i > len(s) {
return 0, false
}
r, size := utf8.DecodeLastRuneInString(s[:i])
if size == 0 {
return 0, false
}
return r, true
}
// detectMentionEntities 检测 @username(messageEntityMention,仅 offset/length 无 user_id)。
// 规则:前导字符非单词字符且非 '@';username = [A-Za-z0-9_] 长 1..32;'@' 计入长度。
// (设置 mentioned 标志是另一条独立链路 mentionedUserIDsFromMessage,基于 user_id,与本检测无关。)
func detectMentionEntities(message string) []tg.MessageEntityClass {
var out []tg.MessageEntityClass
for i := 0; i < len(message); i++ {
if message[i] != '@' {
continue
}
if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '@') {
continue
}
j := i + 1
for j < len(message) && isUsernameByte(message[j]) {
j++
}
if n := j - i - 1; n < 1 || n > 32 {
continue
}
out = append(out, &tg.MessageEntityMention{
Offset: utf16CodeUnitLen(message[:i]),
Length: utf16CodeUnitLen(message[i:j]),
})
i = j - 1
}
return out
}
// detectBotCommandEntities 检测 /command 与 /command@botusername(messageEntityBotCommand)。
// 规则:前导字符非单词字符且非 '/','@','<';command = [A-Za-z0-9_] 长 1..64;可选
// '@' + [A-Za-z0-9_] 1..32 的 bot username 后缀。前导排除单词字符使日期 12/25、and/or、
// url 路径不被误判为命令。
func detectBotCommandEntities(message string) []tg.MessageEntityClass {
var out []tg.MessageEntityClass
for i := 0; i < len(message); i++ {
if message[i] != '/' {
continue
}
if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '/' || r == '@' || r == '<') {
continue
}
j := i + 1
for j < len(message) && isUsernameByte(message[j]) {
j++
}
if n := j - i - 1; n < 1 || n > 64 {
continue
}
end := j
if end < len(message) && message[end] == '@' {
k := end + 1
for k < len(message) && isUsernameByte(message[k]) {
k++
}
if bn := k - end - 1; bn >= 1 && bn <= 32 {
end = k
}
}
out = append(out, &tg.MessageEntityBotCommand{
Offset: utf16CodeUnitLen(message[:i]),
Length: utf16CodeUnitLen(message[i:end]),
})
i = end - 1
}
return out
}
// detectHashtagEntities 检测 #hashtag(messageEntityHashtag,支持 unicode 字母/数字)。
// 规则:前导字符非单词字符且非 '#','@';正文 1..256 个 hashtag 字符且首字符非数字
// (排除 #123 这类纯/前导数字串)。
func detectHashtagEntities(message string) []tg.MessageEntityClass {
var out []tg.MessageEntityClass
// '#' 是 ASCII,绝不出现在多字节 UTF-8 序列内部,故按字节扫描触发字符(避免对每个
// 位置做 rune 解码);仅在边界判定与 body(支持 unicode 字母/数字)上才做 rune 解码。
for i := 0; i < len(message); i++ {
if message[i] != '#' {
continue
}
if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '#' || r == '@') {
continue
}
j := i + 1
var firstRune rune
runeCount := 0
for j < len(message) {
r, size := utf8.DecodeRuneInString(message[j:])
if size <= 0 || !isHashtagRune(r) {
break
}
if runeCount == 0 {
firstRune = r
}
runeCount++
j += size
}
if runeCount >= 1 && runeCount <= 256 && !unicode.IsDigit(firstRune) {
out = append(out, &tg.MessageEntityHashtag{
Offset: utf16CodeUnitLen(message[:i]),
Length: utf16CodeUnitLen(message[i:j]),
})
i = j - 1 // for 循环 i++ 后落到 j,跳过已消费的 hashtag body
}
}
return out
}
// detectCashtagEntities 检测 $TICKER(messageEntityCashtag)。规则:前导字符非单词字符
// 且非 '$';正文 1..8 个大写字母,且其后紧邻字符非单词字符(排除 $USDfoo)。
func detectCashtagEntities(message string) []tg.MessageEntityClass {
var out []tg.MessageEntityClass
for i := 0; i < len(message); i++ {
if message[i] != '$' {
continue
}
if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '$') {
continue
}
j := i + 1
for j < len(message) && message[j] >= 'A' && message[j] <= 'Z' {
j++
}
if n := j - i - 1; n < 1 || n > 8 {
continue
}
if r, size := utf8.DecodeRuneInString(message[j:]); size > 0 && isWordRune(r) {
continue
}
out = append(out, &tg.MessageEntityCashtag{
Offset: utf16CodeUnitLen(message[:i]),
Length: utf16CodeUnitLen(message[i:j]),
})
i = j - 1
}
return out
}

View file

@ -60,10 +60,11 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
},
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
OriginClientSession: clientSessionMetadataFromContext(ctx),
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, messageSendErr(err)
@ -168,10 +169,11 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
},
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
OriginClientSession: clientSessionMetadataFromContext(ctx),
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, internalErr()

View file

@ -338,9 +338,13 @@ func (r *Router) mentionedUserIDsFromDomainMessage(ctx context.Context, currentU
}
}
if identity != nil {
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) {
blocked := mentionScanBlockedSpansFromDomainEntities(message, entities)
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out), blocked) {
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
if err != nil {
if isMentionResolveMiss(err) {
continue
}
return nil, internalErr()
}
if found {

View file

@ -99,8 +99,8 @@ func TestMessagesEditInlineBotMessageEditsPrivateInlineMessage(t *testing.T) {
editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID}
editReq.SetMessage("after edit")
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "done", Data: []byte("v2")}},
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
Buttons: []tg.KeyboardInlineButton{{Text: "done", Type: &tg.InlineButtonTypeCallback{Data: []byte("v2")}}},
}}})
if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok {
t.Fatalf("edit inline bot message = %v,%v, want true,nil", ok, err)
@ -291,8 +291,8 @@ func inlineArticleResultWithCallbackMarkup(id, message, button string, data []by
Title: id,
SendMessage: &tg.InputBotInlineMessageText{
Message: message,
ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: button, Data: data}},
ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
Buttons: []tg.KeyboardInlineButton{{Text: button, Type: &tg.InlineButtonTypeCallback{Data: data}}},
}}},
},
}

View file

@ -53,8 +53,8 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates))
}
newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 {
t.Fatalf("create update = %#v, want channel pts=1", updates.Updates[0])
if !ok || newMsg.Pts != domain.FirstChannelEventPts || newMsg.PtsCount != 1 {
t.Fatalf("create update = %#v, want channel pts=2", updates.Updates[0])
}
if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
t.Fatalf("create refresh = %#v, want channel refresh", updates.Updates[1])
@ -67,8 +67,8 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
t.Fatalf("service action = %T, want channel create", service.Action)
}
inviteMsg, ok := updates.Updates[2].(*tg.UpdateNewChannelMessage)
if !ok || inviteMsg.Pts != 2 || inviteMsg.PtsCount != 1 {
t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[2])
if !ok || inviteMsg.Pts != 3 || inviteMsg.PtsCount != 1 {
t.Fatalf("invite update = %#v, want channel pts=3", updates.Updates[2])
}
if refresh, ok := updates.Updates[3].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
t.Fatalf("invite refresh = %#v, want channel refresh", updates.Updates[3])
@ -296,8 +296,8 @@ func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
t.Fatalf("updates len = %d, want create service message + channel refresh only", len(updates.Updates))
}
created, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
if !ok || created.Pts != 1 || created.PtsCount != 1 {
t.Fatalf("create update = %#v, want pts=1/count=1", updates.Updates[0])
if !ok || created.Pts != domain.FirstChannelEventPts || created.PtsCount != 1 {
t.Fatalf("create update = %#v, want pts=2/count=1", updates.Updates[0])
}
createdMessage, ok := created.Message.(*tg.MessageService)
if !ok {
@ -400,9 +400,13 @@ func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
if err != nil {
t.Fatalf("getChannelDifference from pts=0: %v", err)
}
fullDifference, ok := difference.(*tg.UpdatesChannelDifference)
if !ok || fullDifference.Pts != 1 || len(fullDifference.NewMessages) != 1 {
t.Fatalf("difference = %T %+v, want creation event at pts=1", difference, difference)
fullDifference, ok := difference.(*tg.UpdatesChannelDifferenceTooLong)
if !ok {
t.Fatalf("difference = %T %+v, want baseline-gap snapshot at pts=2", difference, difference)
}
differenceDialog, dialogOK := fullDifference.Dialog.(*tg.Dialog)
if !dialogOK || differenceDialog.Pts != domain.FirstChannelEventPts || len(fullDifference.Messages) != 1 {
t.Fatalf("difference = %T %+v, want baseline-gap snapshot at pts=2", difference, difference)
}
})
}

View file

@ -375,6 +375,14 @@ func dialogDraftErr(err error) error {
}
func (r *Router) clearDraftAfterSend(ctx context.Context, userID int64, peer domain.Peer, replyTo *domain.MessageReply) {
r.clearDraftAfterSendWithOptionalPeerObjects(ctx, userID, peer, replyTo, nil, nil, false)
}
func (r *Router) clearDraftAfterSendWithPeerObjects(ctx context.Context, userID int64, peer domain.Peer, replyTo *domain.MessageReply, users []tg.UserClass, chats []tg.ChatClass) {
r.clearDraftAfterSendWithOptionalPeerObjects(ctx, userID, peer, replyTo, users, chats, true)
}
func (r *Router) clearDraftAfterSendWithOptionalPeerObjects(ctx context.Context, userID int64, peer domain.Peer, replyTo *domain.MessageReply, users []tg.UserClass, chats []tg.ChatClass, peerObjectsReady bool) {
if r.deps.Dialogs == nil || userID == 0 || peer.ID == 0 {
return
}
@ -397,7 +405,9 @@ func (r *Router) clearDraftAfterSend(ctx context.Context, userID int64, peer dom
}
recorded := r.recordDraftMessageEvent(ctx, userID, peer, topMessageID, &date)
r.bookkeepAuxPtsForCurrentSession(ctx, recorded)
users, chats := r.peerObjectsForDraftUpdate(ctx, userID, peer)
if !peerObjectsReady {
users, chats = r.peerObjectsForDraftUpdate(ctx, userID, peer)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded),
Users: users,
@ -982,11 +992,17 @@ func (r *Router) dialogFilterFromRequest(ctx context.Context, userID int64, req
filter.HasFolderID = true
filter.FolderID = folderID
}
if peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.OffsetPeer); err == nil {
// offset_peer is only the stable tie-breaker paired with offset_date/id. It
// neither grants access nor selects content, so resolving a channel view (and
// validating its access_hash) here adds database work without a security
// boundary. Content-bearing peer arguments continue through checkedDomainPeer.
if _, empty := req.OffsetPeer.(*tg.InputPeerEmpty); !empty && !inputPeerClassNil(req.OffsetPeer) {
peer, ok := r.domainPeerFromInputPeer(userID, req.OffsetPeer)
if !ok || peer.ID <= 0 {
return domain.DialogFilter{}, peerIDInvalidErr()
}
filter.HasOffsetPeer = true
filter.OffsetPeer = peer
} else if _, ok := req.OffsetPeer.(*tg.InputPeerEmpty); !ok && req.OffsetPeer != nil {
return domain.DialogFilter{}, err
}
return filter, nil
}

View file

@ -392,7 +392,11 @@ func (r *Router) forwardMessagesToMonoforum(
if len(absentIndexes) == 0 {
results := make([]tg.UpdatesClass, 0, len(replays))
for _, replay := range replays {
results = append(results, r.monoforumSendUpdates(ctx, userID, mono, savedPeer, replay.channel))
updates, err := r.monoforumSendUpdatesStrict(ctx, userID, mono, savedPeer, replay.channel)
if err != nil {
return nil, err
}
results = append(results, updates)
}
return combineSendUpdates(results), nil
}
@ -436,7 +440,11 @@ func (r *Router) forwardMessagesToMonoforum(
results := make([]tg.UpdatesClass, 0, len(req.ID))
for i, source := range sources {
if replays[i].found {
results = append(results, r.monoforumSendUpdates(ctx, userID, mono, savedPeer, replays[i].channel))
updates, err := r.monoforumSendUpdatesStrict(ctx, userID, mono, savedPeer, replays[i].channel)
if err != nil {
return nil, err
}
results = append(results, updates)
continue
}
forward := source.forward

View file

@ -0,0 +1,204 @@
package rpc
import (
"context"
"fmt"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
type getMessagesInputTrace struct {
inputCount int
inputTypes []string
inputIDs []int
lookupIDs []int
invalidIDs []int
replyToIDs []int
callbackMessageIDs []int
callbackQueryIDs []int64
pinnedCount int
unsupportedTypes []string
duplicateLookupIDs []int
}
func newGetMessagesInputTrace(inputs []tg.InputMessageClass) getMessagesInputTrace {
trace := getMessagesInputTrace{
inputCount: len(inputs),
inputTypes: make([]string, 0, len(inputs)),
inputIDs: make([]int, 0, len(inputs)),
lookupIDs: make([]int, 0, len(inputs)),
}
seenLookup := make(map[int]int, len(inputs))
duplicateSeen := make(map[int]struct{})
for _, input := range inputs {
switch msg := input.(type) {
case *tg.InputMessageID:
trace.recordInputID("inputMessageID", msg.ID)
if validMessageBoxID(msg.ID) {
trace.lookupIDs = append(trace.lookupIDs, msg.ID)
seenLookup[msg.ID]++
if seenLookup[msg.ID] == 2 {
trace.duplicateLookupIDs = append(trace.duplicateLookupIDs, msg.ID)
duplicateSeen[msg.ID] = struct{}{}
} else if seenLookup[msg.ID] > 2 {
if _, ok := duplicateSeen[msg.ID]; !ok {
trace.duplicateLookupIDs = append(trace.duplicateLookupIDs, msg.ID)
duplicateSeen[msg.ID] = struct{}{}
}
}
} else {
trace.invalidIDs = append(trace.invalidIDs, msg.ID)
}
case *tg.InputMessageReplyTo:
trace.recordInputID("inputMessageReplyTo", msg.ID)
trace.replyToIDs = append(trace.replyToIDs, msg.ID)
trace.unsupportedTypes = append(trace.unsupportedTypes, "inputMessageReplyTo")
if !validMessageBoxID(msg.ID) {
trace.invalidIDs = append(trace.invalidIDs, msg.ID)
}
case *tg.InputMessagePinned:
trace.inputTypes = append(trace.inputTypes, "inputMessagePinned")
trace.pinnedCount++
trace.unsupportedTypes = append(trace.unsupportedTypes, "inputMessagePinned")
case *tg.InputMessageCallbackQuery:
trace.recordInputID("inputMessageCallbackQuery", msg.ID)
trace.callbackMessageIDs = append(trace.callbackMessageIDs, msg.ID)
trace.callbackQueryIDs = append(trace.callbackQueryIDs, msg.QueryID)
trace.unsupportedTypes = append(trace.unsupportedTypes, "inputMessageCallbackQuery")
if !validMessageBoxID(msg.ID) {
trace.invalidIDs = append(trace.invalidIDs, msg.ID)
}
case nil:
trace.inputTypes = append(trace.inputTypes, "nil")
trace.unsupportedTypes = append(trace.unsupportedTypes, "nil")
default:
name := fmt.Sprintf("%T", input)
trace.inputTypes = append(trace.inputTypes, name)
trace.unsupportedTypes = append(trace.unsupportedTypes, name)
}
}
return trace
}
func (t *getMessagesInputTrace) recordInputID(inputType string, id int) {
t.inputTypes = append(t.inputTypes, inputType)
t.inputIDs = append(t.inputIDs, id)
}
func validMessageBoxID(id int) bool {
return id > 0 && id <= domain.MaxMessageBoxID
}
func (t getMessagesInputTrace) zapFields() []zap.Field {
fields := []zap.Field{
zap.Int("input_count", t.inputCount),
zap.Strings("input_types", t.inputTypes),
zap.Ints("input_ids", t.inputIDs),
zap.Ints("lookup_ids", t.lookupIDs),
}
if len(t.invalidIDs) > 0 {
fields = append(fields, zap.Ints("invalid_ids", t.invalidIDs))
}
if len(t.replyToIDs) > 0 {
fields = append(fields, zap.Ints("reply_to_ids", t.replyToIDs))
}
if t.pinnedCount > 0 {
fields = append(fields, zap.Int("pinned_inputs", t.pinnedCount))
}
if len(t.callbackMessageIDs) > 0 {
fields = append(fields,
zap.Ints("callback_message_ids", t.callbackMessageIDs),
zap.Int64s("callback_query_ids", t.callbackQueryIDs),
)
}
if len(t.unsupportedTypes) > 0 {
fields = append(fields, zap.Strings("unsupported_input_types", t.unsupportedTypes))
}
if len(t.duplicateLookupIDs) > 0 {
fields = append(fields, zap.Ints("duplicate_lookup_ids", t.duplicateLookupIDs))
}
return fields
}
func (t getMessagesInputTrace) missingLookupIDs(found map[int]struct{}) []int {
if len(t.lookupIDs) == 0 {
return nil
}
missing := make([]int, 0)
seenMissing := make(map[int]struct{})
for _, id := range t.lookupIDs {
if _, ok := found[id]; ok {
continue
}
if _, ok := seenMissing[id]; ok {
continue
}
missing = append(missing, id)
seenMissing[id] = struct{}{}
}
return missing
}
func (r *Router) logPrivateGetMessagesTrace(ctx context.Context, trace getMessagesInputTrace, found []domain.Message, result *tg.MessagesMessages) {
if r == nil || r.log == nil || result == nil {
return
}
foundIDs := make([]int, 0, len(found))
foundSet := make(map[int]struct{}, len(found))
peers := make([]string, 0, len(found))
for _, msg := range found {
foundIDs = append(foundIDs, msg.ID)
foundSet[msg.ID] = struct{}{}
peers = append(peers, fmt.Sprintf("id=%d peer=%s:%d from=%s:%d out=%t uid=%d pts=%d",
msg.ID, msg.Peer.Type, msg.Peer.ID, msg.From.Type, msg.From.ID, msg.Out, msg.UID, msg.Pts))
}
fields := append([]zap.Field{zap.String("method", "messages.getMessages")}, r.contextLogFields(ctx)...)
fields = append(fields, trace.zapFields()...)
fields = append(fields,
zap.Ints("found_ids", foundIDs),
zap.Ints("missing_lookup_ids", trace.missingLookupIDs(foundSet)),
zap.Strings("found_peers", peers),
zap.Int("result_messages", len(result.Messages)),
zap.Int("result_users", len(result.Users)),
zap.Int("result_chats", len(result.Chats)),
)
r.log.Info("messages.getMessages detail", fields...)
}
func (r *Router) logChannelGetMessagesTrace(ctx context.Context, channelID int64, trace getMessagesInputTrace, found []domain.ChannelMessage, result *tg.MessagesMessages) {
if r == nil || r.log == nil {
return
}
foundIDs := make([]int, 0, len(found))
foundSet := make(map[int]struct{}, len(found))
peers := make([]string, 0, len(found))
for _, msg := range found {
foundIDs = append(foundIDs, msg.ID)
foundSet[msg.ID] = struct{}{}
peers = append(peers, fmt.Sprintf("id=%d channel=%d from=%s:%d sender_user_id=%d post=%t pts=%d",
msg.ID, msg.ChannelID, msg.From.Type, msg.From.ID, msg.SenderUserID, msg.Post, msg.Pts))
}
resultMessages, resultUsers, resultChats := 0, 0, 0
if result != nil {
resultMessages = len(result.Messages)
resultUsers = len(result.Users)
resultChats = len(result.Chats)
}
fields := append([]zap.Field{
zap.String("method", "channels.getMessages"),
zap.Int64("channel_id", channelID),
}, r.contextLogFields(ctx)...)
fields = append(fields, trace.zapFields()...)
fields = append(fields,
zap.Ints("found_ids", foundIDs),
zap.Ints("missing_lookup_ids", trace.missingLookupIDs(foundSet)),
zap.Strings("found_peers", peers),
zap.Int("result_messages", resultMessages),
zap.Int("result_users", resultUsers),
zap.Int("result_chats", resultChats),
)
r.log.Info("channels.getMessages detail", fields...)
}

View file

@ -502,14 +502,8 @@ func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessag
}
out := make([]tg.MessageClass, 0, len(ids))
requestedIDs := make([]int, 0, len(ids))
for _, input := range ids {
id, ok := inputMessageBoxID(input)
if !ok || id <= 0 || id > domain.MaxMessageBoxID {
continue
}
requestedIDs = append(requestedIDs, id)
}
trace := newGetMessagesInputTrace(ids)
requestedIDs := trace.lookupIDs
list, err := r.deps.Messages.GetMessages(ctx, userID, requestedIDs)
if err != nil {
return nil, internalErr()
@ -533,13 +527,15 @@ func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessag
found = append(found, msg)
out = append(out, tgMessage(msg))
}
r.maybeEnqueueExpiredPrivateWebPageResolves(found)
chats := r.chatsForMessageUpdates(ctx, userID, found)
result := &tg.MessagesMessages{
Messages: out,
Users: r.usersForMessageUpdates(ctx, userID, found),
Users: r.usersForMessageUpdatesWithPreloaded(ctx, userID, found, r.preloadedMessageUsers(list)),
Chats: chats,
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
r.logPrivateGetMessagesTrace(ctx, trace, found, result)
return result, nil
}
@ -583,7 +579,7 @@ func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesG
}
result := &tg.MessagesMessages{
Messages: out,
Users: r.usersForMessageUpdates(ctx, userID, found),
Users: r.usersForMessageUpdatesWithPreloaded(ctx, userID, found, r.preloadedMessageUsers(list)),
Chats: r.chatsForMessageUpdates(ctx, userID, found),
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
@ -669,8 +665,7 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
}
}
if req.UsersOnly || r.deps.Channels == nil {
result := appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView)
r.applyPeerReadModelsToMessages(ctx, userID, result)
result := appendCommunitySearchChat(r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView)
return result, nil
}
channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{
@ -851,6 +846,10 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
MusicOnly: messagesSearchFilterMusic(req.Filter),
NeedTotalCount: req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0,
}
if phoneCalls, ok := req.Filter.(*tg.InputMessagesFilterPhoneCalls); ok {
filter.PhoneCallsOnly = true
filter.MissedPhoneCallsOnly = phoneCalls.Missed
}
if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok {
filter.HasPeer = true
filter.Peer = peer
@ -953,7 +952,7 @@ func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool {
func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool {
switch filter.(type) {
case nil, *tg.InputMessagesFilterEmpty:
case nil, *tg.InputMessagesFilterEmpty, *tg.InputMessagesFilterPhoneCalls:
return false
case *tg.InputMessagesFilterPhotos,
*tg.InputMessagesFilterVideo,

View file

@ -607,6 +607,43 @@ func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) {
}
}
func TestMessagesSearchMediaPreservesCombinedFilters(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
alice, err := users.Create(ctx, domain.User{AccessHash: 511, Phone: "15550000511", FirstName: "Alice"})
if err != nil {
t.Fatal(err)
}
bob, err := users.Create(ctx, domain.User{AccessHash: 512, Phone: "15550000512", FirstName: "Bob"})
if err != nil {
t.Fatal(err)
}
messages := &captureMessages{}
r := New(Config{}, Deps{Messages: messages, Users: appusers.NewService(users)}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSearchRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
Q: "invoice", FromID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
Filter: &tg.InputMessagesFilterPhotos{}, MinDate: 100, MaxDate: 200,
OffsetID: 90, AddOffset: 3, Limit: 20, MaxID: 80, MinID: 10,
}
req.SetTopMsgID(7)
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := r.Dispatch(WithUserID(ctx, bob.ID), [8]byte{}, 0, &in); err != nil {
t.Fatalf("messages.search media: %v", err)
}
got := messages.mediaReq
if got.Query != "invoice" || got.SenderUserID != alice.ID || got.MinDate != 100 || got.MaxDate != 200 ||
got.TopMsgID != 7 || got.OffsetID != 90 || got.AddOffset != 3 || got.Limit != 20 || got.MaxID != 80 || got.MinID != 10 {
t.Fatalf("media request = %+v", got)
}
if len(got.Categories) != 1 || got.Categories[0] != domain.MediaCategoryPhoto {
t.Fatalf("media categories = %v", got.Categories)
}
}
func TestMessagesSetTypingPushesUserTypingUpdate(t *testing.T) {
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"errors"
"sort"
"github.com/iamxvbaba/td/tg"
@ -91,11 +92,15 @@ func (r *Router) monoforumSavedDialogs(ctx context.Context, userID int64, mono d
messages = append(messages, item)
}
}
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, userID, list.Dialogs, list.Messages, nil, nil)
if err != nil {
return nil, internalErr()
}
return &tg.MessagesSavedDialogs{
Dialogs: dialogs,
Messages: messages,
Chats: r.monoforumChats(ctx, userID, mono),
Users: r.monoforumSubscriberUsers(ctx, userID, list.Dialogs, list.Messages),
Users: users,
}, nil
}
@ -119,11 +124,15 @@ func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono d
messages = append(messages, item)
}
}
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, userID, nil, hist.Messages, nil, nil)
if err != nil {
return nil, internalErr()
}
result := &tg.MessagesMessagesSlice{
Count: hist.Count,
Messages: messages,
Chats: r.monoforumChats(ctx, userID, mono),
Users: r.monoforumSubscriberUsers(ctx, userID, nil, hist.Messages),
Users: users,
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
@ -145,8 +154,13 @@ func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.C
return chats
}
// monoforumSubscriberUsers 投影订阅者用户(子会话 saved_peer + 消息发件人)。
func (r *Router) monoforumSubscriberUsers(ctx context.Context, userID int64, dialogs []domain.MonoforumDialog, messages []domain.ChannelMessage) []tg.UserClass {
type monoforumPeerOverlays struct {
usernames map[domain.Peer][]domain.Username
botProfiles map[int64]domain.BotProfile
botVerifications map[domain.Peer]domain.CustomVerification
}
func monoforumSubscriberUserIDs(dialogs []domain.MonoforumDialog, messages []domain.ChannelMessage) []int64 {
ids := make([]int64, 0, len(dialogs)+len(messages))
seen := map[int64]struct{}{}
add := func(id int64) {
@ -167,14 +181,132 @@ func (r *Router) monoforumSubscriberUsers(ctx context.Context, userID int64, dia
for _, m := range messages {
add(m.SenderUserID)
}
if len(ids) == 0 || r.deps.Users == nil {
return []tg.UserClass{}
// tgChannelMessage can reference users beyond its sender (from/send_as,
// forward, via_bot, reply/quote mention, contact/poll/todo/action/reaction).
// Reuse the common channel-message closure so saved history, send echo and
// online fan-out all materialize the same complete Users envelope.
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
for _, message := range messages {
collectChannelMessagePeerRefs(message, message.ChannelID, userIDs, channelIDs)
}
found, err := r.deps.Users.ByIDs(ctx, userID, ids)
extra := peerIDMapKeys(userIDs)
sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] })
for _, id := range extra {
add(id)
}
return ids
}
func monoforumProjectionPeers(monoforumID, parentID int64, userIDs []int64) []domain.Peer {
peers := make([]domain.Peer, 0, len(userIDs)+2)
seen := make(map[domain.Peer]struct{}, len(userIDs)+2)
add := func(peer domain.Peer) {
if peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
peers = append(peers, peer)
}
for _, userID := range userIDs {
add(domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
add(domain.Peer{Type: domain.PeerTypeChannel, ID: monoforumID})
add(domain.Peer{Type: domain.PeerTypeChannel, ID: parentID})
return peers
}
// loadMonoforumPeerOverlays resolves peer-wide facts once before a fan-out. The
// returned snapshot is immutable for the lifetime of that job and can therefore
// be reused by every viewer builder without turning overlays into N+1 reads.
func (r *Router) loadMonoforumPeerOverlays(ctx context.Context, peers []domain.Peer) *monoforumPeerOverlays {
overlays := &monoforumPeerOverlays{
usernames: r.usernameRegistryMap(ctx, peers),
botVerifications: r.botVerificationMap(ctx, peers),
}
if r.deps.Bots == nil {
return overlays
}
userIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == domain.PeerTypeUser && peer.ID != 0 {
userIDs = append(userIDs, peer.ID)
}
}
if len(userIDs) == 0 {
return overlays
}
if batch, ok := r.deps.Bots.(botProfileBatchResolver); ok {
if profiles, err := batch.BotInfos(ctx, userIDs); err == nil {
overlays.botProfiles = profiles
return overlays
}
}
overlays.botProfiles = make(map[int64]domain.BotProfile)
for _, userID := range userIDs {
if profile, found, err := r.deps.Bots.BotInfo(ctx, userID); err == nil && found {
overlays.botProfiles[userID] = profile
}
}
return overlays
}
func applyMonoforumPeerOverlays(users []tg.UserClass, chats []tg.ChatClass, overlays *monoforumPeerOverlays) {
if overlays == nil {
return
}
applyUsernamesFromRegistry(users, chats, overlays.usernames)
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil || u.Deleted {
continue
}
if u.Bot {
if profile, found := overlays.botProfiles[u.ID]; found {
applyBotProfileFlags(u, profile)
}
}
if mark, found := overlays.botVerifications[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]; found && mark.IconDocumentID > 0 {
u.SetBotVerificationIcon(mark.IconDocumentID)
}
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
if mark, found := overlays.botVerifications[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]; found && mark.IconDocumentID > 0 {
ch.SetBotVerificationIcon(mark.IconDocumentID)
}
}
}
// monoforumSubscriberUsers projects subscriber users (saved_peer + message
// senders) for one viewer. Fan-out callers pass their preheated peer cache and
// overlay snapshot; single-viewer callers retain the same output shape through a
// one-shot local cache. The pure TL conversion is viewer-aware so self is never
// lost for a subscriber viewing their own envelope.
func (r *Router) monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx context.Context, userID int64, dialogs []domain.MonoforumDialog, messages []domain.ChannelMessage, cache *viewerPeerCache, overlays *monoforumPeerOverlays) ([]tg.UserClass, error) {
ids := monoforumSubscriberUserIDs(dialogs, messages)
if len(ids) == 0 {
return []tg.UserClass{}, nil
}
if cache == nil {
cache = newViewerPeerCache(r)
}
projected, err := cache.usersForIDsStrict(ctx, userID, ids)
if err != nil {
return []tg.UserClass{}
return nil, err
}
return r.tgUsers(found)
if overlays == nil {
overlays = r.loadMonoforumPeerOverlays(ctx, monoforumProjectionPeers(0, 0, ids))
}
out := tgUsersForViewer(userID, projected)
applyMonoforumPeerOverlays(out, nil, overlays)
return out, nil
}
// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否显式携带 monoforum_peer_id。
@ -283,12 +415,21 @@ func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer do
if !res.Duplicate {
r.enqueueMonoforumMessageFanout(ctx, userID, mono, req.SavedPeer, res)
}
return r.monoforumSendUpdates(ctx, userID, mono, req.SavedPeer, res), nil
return r.monoforumSendUpdatesStrict(ctx, userID, mono, req.SavedPeer, res)
}
// 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 {
func (r *Router) monoforumSendUpdatesStrict(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) (tg.UpdatesClass, error) {
return r.monoforumSendUpdatesWithPeerCacheAndOverlaysStrict(ctx, userID, mono, savedPeer, res, nil, nil)
}
func (r *Router) monoforumSendUpdatesWithPeerCacheAndOverlays(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) tg.UpdatesClass {
updates, _ := r.monoforumSendUpdatesWithPeerCacheAndOverlaysStrict(ctx, userID, mono, savedPeer, res, cache, overlays)
return updates
}
func (r *Router) monoforumSendUpdatesWithPeerCacheAndOverlaysStrict(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) (tg.UpdatesClass, error) {
updates := make([]tg.UpdateClass, 0, 3)
if res.Message.RandomID != 0 {
updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: res.Message.RandomID})
@ -309,16 +450,35 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
date = res.ReplayDeleteEvent.Date
}
}
dialogs := []domain.MonoforumDialog{{SavedPeer: savedPeer}}
messages := []domain.ChannelMessage{res.Message}
if cache == nil {
cache = newViewerPeerCache(r)
}
if overlays == nil {
ids := monoforumSubscriberUserIDs(dialogs, messages)
overlays = r.loadMonoforumPeerOverlays(ctx, monoforumProjectionPeers(mono.ID, mono.LinkedMonoforumID, ids))
}
chats := r.monoforumChats(ctx, userID, mono)
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, userID, dialogs, messages, cache, overlays)
if err != nil {
return nil, err
}
applyMonoforumPeerOverlays(nil, chats, overlays)
return &tg.Updates{
Updates: updates,
Chats: r.monoforumChats(ctx, userID, mono),
Users: r.monoforumSubscriberUsers(ctx, userID, []domain.MonoforumDialog{{SavedPeer: savedPeer}}, []domain.ChannelMessage{res.Message}),
Chats: chats,
Users: users,
Date: date,
}
}, nil
}
func (r *Router) monoforumDeliveryUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) *tg.Updates {
updates, _ := r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res).(*tg.Updates)
return r.monoforumDeliveryUpdatesWithPeerCacheAndOverlays(ctx, userID, mono, savedPeer, res, nil, nil)
}
func (r *Router) monoforumDeliveryUpdatesWithPeerCacheAndOverlays(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) *tg.Updates {
updates, _ := r.monoforumSendUpdatesWithPeerCacheAndOverlays(ctx, userID, mono, savedPeer, res, cache, overlays).(*tg.Updates)
if updates == nil {
return nil
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"errors"
"strings"
"testing"
@ -17,6 +18,93 @@ import (
"telesrv/internal/store/memory"
)
func TestMonoforumSendUpdatesIncludesCompleteMessageUserEnvelope(t *testing.T) {
const (
viewerID = int64(1000000201)
savedPeerID = int64(1000000202)
viaBotID = int64(1000000203)
replyPeerID = int64(1000000204)
quoteUserID = int64(1000000205)
monoforumID = int64(1000000291)
messageID = 41
messagePts = 51
)
users := map[int64]domain.User{}
for _, id := range []int64{viewerID, savedPeerID, viaBotID, replyPeerID, quoteUserID} {
users[id] = domain.User{ID: id, FirstName: "projected"}
}
router := New(Config{}, Deps{Users: mapUsersService{users: users}}, zaptest.NewLogger(t), clock.System)
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: savedPeerID}
message := domain.ChannelMessage{
ChannelID: monoforumID, ID: messageID, SenderUserID: viewerID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, SavedPeer: savedPeer,
ViaBotID: viaBotID, Date: 1700000600,
ReplyTo: &domain.MessageReply{
MessageID: 40,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: replyPeerID},
QuoteEntities: []domain.MessageEntity{{
Type: domain.MessageEntityMentionName, UserID: quoteUserID,
}},
},
}
result := domain.SendChannelMessageResult{
Channel: domain.Channel{ID: monoforumID, Monoforum: true},
Message: message,
Event: domain.ChannelUpdateEvent{
ChannelID: monoforumID, Type: domain.ChannelUpdateNewMessage,
Pts: messagePts, PtsCount: 1, Message: message,
},
}
updatesClass, err := router.monoforumSendUpdatesStrict(context.Background(), viewerID, result.Channel, savedPeer, result)
if err != nil {
t.Fatalf("monoforumSendUpdatesStrict: %v", err)
}
updates, ok := updatesClass.(*tg.Updates)
if !ok || updates == nil {
t.Fatalf("monoforumSendUpdates = %T, want *tg.Updates", updates)
}
got := make(map[int64]bool, len(updates.Users))
for _, item := range updates.Users {
if user, ok := item.(*tg.User); ok {
got[user.ID] = true
}
}
for _, id := range []int64{viewerID, savedPeerID, viaBotID, replyPeerID, quoteUserID} {
if !got[id] {
t.Fatalf("Users = %+v, missing referenced user %d", got, id)
}
}
}
func TestMonoforumSendUpdatesFailsClosedOnIncompleteUserEnvelope(t *testing.T) {
const (
viewerID = int64(1000000301)
savedPeerID = int64(1000000302)
missingBot = int64(1000000303)
monoforumID = int64(1000000391)
)
router := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
viewerID: {ID: viewerID, FirstName: "viewer"},
savedPeerID: {ID: savedPeerID, FirstName: "saved peer"},
}}}, zaptest.NewLogger(t), clock.System)
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: savedPeerID}
message := domain.ChannelMessage{
ChannelID: monoforumID, ID: 1, SenderUserID: viewerID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, SavedPeer: savedPeer,
ViaBotID: missingBot, Date: 1700000700,
}
result := domain.SendChannelMessageResult{
Channel: domain.Channel{ID: monoforumID, Monoforum: true}, Message: message,
Event: domain.ChannelUpdateEvent{ChannelID: monoforumID, Type: domain.ChannelUpdateNewMessage, Pts: 2, PtsCount: 1, Message: message},
}
updates, err := router.monoforumSendUpdatesStrict(context.Background(), viewerID, result.Channel, savedPeer, result)
if !errors.Is(err, ErrDurableUserProjectionIncomplete) || updates != nil {
t.Fatalf("monoforum strict envelope = %T, %v; want nil ErrDurableUserProjectionIncomplete", updates, err)
}
}
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC管理员经
// getSavedDialogs/getSavedHistory 看订阅者子会话parent_peer 同时兼容 TDesktop 实际发送的
// 母广播频道和虚拟 monoforum订阅者经普通 getHistory 只看自己的子会话。

View file

@ -177,6 +177,8 @@ func messageReactionErr(err error) error {
func channelReactionErr(err error) error {
switch {
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
return randomIDDuplicateErr()
case errors.Is(err, domain.ErrMessageIDInvalid):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrReactionInvalid):

View file

@ -293,7 +293,11 @@ func tgReadHistoryInboxUpdate(event domain.UpdateEvent) tg.UpdateClass {
}
return update
}
return tgReadHistoryInbox(event)
update := tgReadHistoryInbox(event)
if update == nil {
return nil
}
return update
}
func tgReadHistoryOutbox(event domain.UpdateEvent) *tg.UpdateReadHistoryOutbox {
@ -316,5 +320,9 @@ func tgReadHistoryOutboxUpdate(event domain.UpdateEvent) tg.UpdateClass {
MaxID: event.MaxID,
}
}
return tgReadHistoryOutbox(event)
update := tgReadHistoryOutbox(event)
if update == nil {
return nil
}
return update
}

View file

@ -2,11 +2,13 @@ package rpc
import (
"context"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
"unicode/utf8"
)
// registerMessages 注册 messages.* RPC handler。
@ -432,6 +434,21 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
return &tg.MessagesDialogsNotModified{Count: hashCheck.Count}, nil
}
}
type pinnedLoadResult struct {
list domain.DialogList
err error
}
var pinnedLoad <-chan pinnedLoadResult
if ClientTypeFrom(ctx) == ClientTypeTDesktop && tdesktop.ShouldMergePinnedIntoInitialDialogs(filter) {
pinnedCtx, cancelPinned := context.WithCancel(ctx)
defer cancelPinned()
results := make(chan pinnedLoadResult, 1)
pinnedLoad = results
go func() {
list, err := r.pinnedDialogsList(pinnedCtx, userID, domain.DialogMainFolderID)
results <- pinnedLoadResult{list: list, err: err}
}()
}
list, err := r.deps.Dialogs.GetDialogs(ctx, userID, filter)
if err != nil {
return nil, internalErr()
@ -440,16 +457,18 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil {
return nil, communityErr(err)
}
if ClientTypeFrom(ctx) == ClientTypeTDesktop && tdesktop.ShouldMergePinnedIntoInitialDialogs(filter) {
pinned, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID)
if err != nil {
if pinnedLoad != nil {
pinned := <-pinnedLoad
if pinned.err != nil {
return nil, internalErr()
}
list = tdesktop.MergeInitialDialogsWithPinned(list, pinned)
}
if filter.Hash != 0 && r.deps.Communities == nil && list.Hash == filter.Hash {
return &tg.MessagesDialogsNotModified{Count: list.Count}, nil
list = tdesktop.MergeInitialDialogsWithPinned(list, pinned.list)
}
// An unknown cache entry is also the invalidation signal for metadata that
// does not alter dialog ordering (for example verified/scam/fake flags).
// In that case the peer objects must be sent once even when the freshly
// computed list hash still equals the client's hash. The response warms the
// cache, so later identical requests retain the fast NotModified path above.
return r.tgMessagesDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list)), nil
})
registerRPC[*tg.MessagesGetPinnedDialogsRequest](d, tlprofile.SemanticMethodMessagesGetPinnedDialogs, func(ctx context.Context, layerRequest *tg.MessagesGetPinnedDialogsRequest) (any, error) {
@ -720,6 +739,12 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if isLegacyInputPeerChat(req.Peer) {
return &tg.MessagesMessages{}, nil
}
// P2P calls are stored exclusively in private message boxes. Returning
// an empty result is important here: falling through to channel history
// would make ordinary channel posts appear in the Calls tab.
if filter.PhoneCallsOnly {
return &tg.MessagesMessages{}, nil
}
if messagesSearchFilterChatPhotos(req.Filter) {
view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID)
if err != nil {
@ -758,15 +783,11 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil {
return nil, err
}
categories := mediaCategoriesForFilter(req.Filter)
mediaReq := domain.MediaSearchRequest{
Categories: categories,
OffsetID: req.OffsetID,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: req.Limit,
MaxID: req.MaxID,
MinID: req.MinID,
mediaReq, err := r.mediaSearchRequestFromMessagesSearch(ctx, userID, req, filter)
if err != nil {
return nil, err
}
categories := mediaReq.Categories
if mediaSearchCanReusePeerWideCount(req) {
counts, err := r.mediaCountsForPeer(ctx, userID, filter.Peer)
if err != nil {
@ -843,15 +864,11 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
Count: counts.CountAny(mediaCategoriesForFilter(req.Filter)),
}), nil
}
categories := mediaCategoriesForFilter(req.Filter)
mediaReq := domain.MediaSearchRequest{
Categories: categories,
OffsetID: req.OffsetID,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: req.Limit,
MaxID: req.MaxID,
MinID: req.MinID,
mediaReq, err := r.mediaSearchRequestFromMessagesSearch(ctx, userID, req, filter)
if err != nil {
return nil, err
}
categories := mediaReq.Categories
if mediaSearchCanReusePeerWideCount(req) {
counts, err := r.mediaCountsForPeer(ctx, userID, peer)
if err != nil {

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"encoding/binary"
"encoding/json"
"strings"
"testing"
@ -20,6 +21,163 @@ import (
"telesrv/internal/store/memory"
)
func TestStoredRichMessageMissingLayerDecodesAsExact228(t *testing.T) {
legacyBlocks := []tg.PageBlockClass{&tg.PageBlockBlockquote{
Text: &tg.TextPlain{Text: "legacy quote"},
Caption: &tg.TextEmpty{},
}}
var wire bin.Buffer
if err := tlprofile.EncodePageBlockVector(tlprofile.Profile228, legacyBlocks, &wire); err != nil {
t.Fatal(err)
}
raw := wire.Copy()
rich := &domain.MessageRichMessage{Blocks: raw}
got, err := tgRichMessage(rich)
if err != nil {
t.Fatal(err)
}
if rich.BlocksLayer != 0 || string(rich.Blocks) != string(raw) {
t.Fatal("legacy read mutated the persisted snapshot")
}
quote, ok := got.Blocks[0].(*tg.PageBlockBlockquote)
if !ok {
t.Fatalf("decoded block = %T", got.Blocks[0])
}
if quote.Collapsed {
t.Fatal("Layer 228 blockquote acquired Layer 229 collapsed state")
}
text, ok := quote.Text.(*tg.TextPlain)
if !ok || text.Text != "legacy quote" {
t.Fatalf("decoded quote text = %#v", quote.Text)
}
}
func TestNewRichMessageStoresExact229Profile(t *testing.T) {
r := &Router{}
rich, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessage{
Blocks: []tg.PageBlockClass{&tg.PageBlockBlockquote{
Collapsed: true,
Text: &tg.TextPlain{Text: "current quote"},
Caption: &tg.TextEmpty{},
}},
})
if err != nil {
t.Fatal(err)
}
if rich.BlocksLayer != int(tlprofile.ProfileCanonical) {
t.Fatalf("stored blocks layer = %d, want canonical %d", rich.BlocksLayer, tlprofile.ProfileCanonical)
}
if got := binary.LittleEndian.Uint32(rich.Blocks[8:12]); got != 0x66d1670b {
t.Fatalf("stored blockquote constructor = %#08x, want Layer 229", got)
}
}
func TestLayer228SenderRichMessageProjectsToLayer229Receiver(t *testing.T) {
canonical := []tg.PageBlockClass{&tg.PageBlockBlockquote{
Text: &tg.TextPlain{Text: "cross-layer quote"},
Caption: &tg.TextEmpty{},
}}
var senderWire bin.Buffer
if err := tlprofile.EncodePageBlockVector(tlprofile.Profile228, canonical, &senderWire); err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(senderWire.Raw()[8:12]); got != 0x263d7c26 {
t.Fatalf("Layer 228 sender constructor = %#08x", got)
}
decodedSender, err := tlprofile.DecodePageBlockVector(
tlprofile.Profile228,
&bin.Buffer{Buf: senderWire.Copy()},
tlprofile.Limits{},
)
if err != nil {
t.Fatal(err)
}
r := &Router{}
stored, err := r.domainRichMessageFromInput(context.Background(), &tg.InputRichMessage{Blocks: decodedSender})
if err != nil {
t.Fatal(err)
}
if stored.BlocksLayer != int(tlprofile.ProfileCanonical) {
t.Fatalf("storage layer = %d, want canonical %d", stored.BlocksLayer, tlprofile.ProfileCanonical)
}
if got := binary.LittleEndian.Uint32(stored.Blocks[8:12]); got != 0x66d1670b {
t.Fatalf("storage constructor = %#08x, want Layer 229", got)
}
projected, err := tgRichMessage(stored)
if err != nil {
t.Fatal(err)
}
quote := projected.Blocks[0].(*tg.PageBlockBlockquote)
if quote.Collapsed {
t.Fatal("Layer 228 sender acquired collapsed=true")
}
var receiverWire bin.Buffer
if err := tlprofile.EncodePageBlockVector(tlprofile.Profile229, projected.Blocks, &receiverWire); err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(receiverWire.Raw()[8:12]); got != 0x66d1670b {
t.Fatalf("Layer 229 receiver constructor = %#08x", got)
}
}
func TestLayer228ReceiverSkipsLayer229OnlyRichBlocks(t *testing.T) {
message := &tg.Message{
ID: 42,
PeerID: &tg.PeerUser{UserID: 1001},
Date: 1700000000,
Message: "base message",
}
message.SetRichMessage(tg.RichMessage{Blocks: []tg.PageBlockClass{
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "compatible"}},
&tg.PageBlockButtonRow{},
}})
var wire bin.Buffer
if err := tlprofile.EncodeObject(tlprofile.Profile228, message, &wire); err != nil {
t.Fatal(err)
}
decoded, err := tlprofile.DecodeObject(tlprofile.Profile228, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
if err != nil {
t.Fatal(err)
}
projected, ok := decoded.(*tg.Message)
if !ok {
t.Fatalf("decoded message = %T", decoded)
}
if projected.Message != "base message" {
t.Fatalf("base message = %q", projected.Message)
}
rich, present := projected.GetRichMessage()
if !present {
t.Fatal("compatible rich_message was dropped")
}
if len(rich.Blocks) != 1 {
t.Fatalf("Layer 228 rich blocks = %d, want one compatible block", len(rich.Blocks))
}
if _, ok := rich.Blocks[0].(*tg.PageBlockParagraph); !ok {
t.Fatalf("remaining block = %T", rich.Blocks[0])
}
}
func TestInvalidStoredRichMessageDoesNotPanicBaseProjection(t *testing.T) {
projected, ok := tgMessage(domain.Message{
ID: 42,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
RichMessage: &domain.MessageRichMessage{
BlocksLayer: 999,
Blocks: []byte{1, 2, 3, 4},
},
}).(*tg.Message)
if !ok {
t.Fatal("base message projection was lost")
}
if _, present := projected.GetRichMessage(); present {
t.Fatal("invalid optional rich_message was projected")
}
}
// richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。
func richTextBlocks() []tg.PageBlockClass {
return richTextBlocksWith("Rich Title", "First paragraph.")

View file

@ -353,8 +353,7 @@ func (r *Router) savedDialogsProjection(ctx context.Context, userID int64, list
}
}
}
r.applyUsernamesToPeerObjects(ctx, users, chats)
r.applyBotVerificationIconsToPeerObjects(ctx, users, chats)
r.applyPeerIdentitiesToPeerObjects(ctx, users, chats)
return users, chats
}

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"errors"
"sort"
"strings"
"unicode/utf8"
@ -116,7 +117,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil
updates, projectionErr := r.monoforumSendUpdatesStrict(ctx, userID, replay.channel.Channel, savedPeer, replay.channel)
if projectionErr != nil {
sendErr = projectionErr
return nil, projectionErr
}
return updates, nil
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
sendErr = err
@ -325,7 +331,7 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d
replyPeer := peer
if inputPeer, ok := reply.GetReplyToPeerID(); ok {
parsed, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer)
if err != nil || parsed != peer {
if err != nil {
return nil, replyMessageIDInvalidErr()
}
replyPeer = parsed
@ -340,6 +346,19 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d
if reply.ReplyToMsgID == 0 && topMsgID == 0 {
return nil, replyMessageIDInvalidErr()
}
// inputReplyToMessage.reply_to_peer_id is explicitly allowed to point to a
// different dialog. Private-source existence is checked transactionally by
// MessageStore; channel sources are validated here because they live in the
// channel store rather than message_boxes.
if replyPeer.Type == domain.PeerTypeChannel && reply.ReplyToMsgID > 0 {
if r.deps.Channels == nil {
return nil, replyMessageIDInvalidErr()
}
history, err := r.deps.Channels.GetMessages(ctx, userID, replyPeer.ID, []int{reply.ReplyToMsgID})
if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != reply.ReplyToMsgID {
return nil, replyMessageIDInvalidErr()
}
}
quoteText, _ := reply.GetQuoteText()
if utf8.RuneCountInString(quoteText) > maxReplyQuoteLength {
return nil, limitInvalidErr()
@ -434,9 +453,13 @@ func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID
}
}
if identity != nil {
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) {
blocked := mentionScanBlockedSpansFromTGEntities(message, entities)
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out), blocked) {
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
if err != nil {
if isMentionResolveMiss(err) {
continue
}
return nil, internalErr()
}
if found {
@ -450,16 +473,29 @@ func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID
return out, nil
}
func extractMentionUsernames(message string, limit int) []string {
func isMentionResolveMiss(err error) bool {
return errors.Is(err, domain.ErrUsernameInvalid) || errors.Is(err, domain.ErrUsernameNotOccupied)
}
func extractMentionUsernames(message string, limit int, blocked []byteSpan) []string {
if limit <= 0 || message == "" {
return nil
}
blocked = mergeByteSpans(append(blocked, rawURLByteSpans(message)...))
blockIndex := 0
seen := make(map[string]struct{})
out := make([]string, 0)
for i := 0; i < len(message); i++ {
if message[i] != '@' {
continue
}
for blockIndex < len(blocked) && blocked[blockIndex].end <= i {
blockIndex++
}
if blockIndex < len(blocked) && blocked[blockIndex].start <= i && i < blocked[blockIndex].end {
i = blocked[blockIndex].end - 1
continue
}
if i > 0 && isUsernameByte(message[i-1]) {
continue
}
@ -484,6 +520,110 @@ func extractMentionUsernames(message string, limit int) []string {
return out
}
func mergeByteSpans(spans []byteSpan) []byteSpan {
if len(spans) == 0 {
return nil
}
out := spans[:0]
for _, span := range spans {
if span.start < 0 || span.end <= span.start {
continue
}
inserted := false
for i := range out {
if span.start < out[i].start {
out = append(out, byteSpan{})
copy(out[i+1:], out[i:])
out[i] = span
inserted = true
break
}
}
if !inserted {
out = append(out, span)
}
}
if len(out) == 0 {
return nil
}
merged := out[:1]
for _, span := range out[1:] {
last := &merged[len(merged)-1]
if span.start <= last.end {
if span.end > last.end {
last.end = span.end
}
continue
}
merged = append(merged, span)
}
return merged
}
func mentionScanBlockedSpansFromTGEntities(message string, entities []tg.MessageEntityClass) []byteSpan {
if len(entities) == 0 {
return nil
}
bounds := utf16ByteBoundaries(message)
var out []byteSpan
for _, entity := range entities {
switch entity.(type) {
case *tg.MessageEntityURL, *tg.MessageEntityTextURL:
default:
continue
}
if span, ok := byteSpanFromUTF16Bounds(bounds, entity.GetOffset(), entity.GetLength()); ok {
out = append(out, span)
}
}
return out
}
func mentionScanBlockedSpansFromDomainEntities(message string, entities []domain.MessageEntity) []byteSpan {
if len(entities) == 0 {
return nil
}
bounds := utf16ByteBoundaries(message)
var out []byteSpan
for _, entity := range entities {
if entity.Type != domain.MessageEntityURL && entity.Type != domain.MessageEntityTextURL {
continue
}
if span, ok := byteSpanFromUTF16Bounds(bounds, entity.Offset, entity.Length); ok {
out = append(out, span)
}
}
return out
}
func utf16ByteBoundaries(message string) []int {
total := utf16CodeUnitLen(message)
bounds := make([]int, total+1)
for i := range bounds {
bounds[i] = -1
}
unit := 0
bounds[0] = 0
for i, r := range message {
bounds[unit] = i
if r <= 0xFFFF {
unit++
} else {
unit += 2
}
bounds[unit] = i + utf8.RuneLen(r)
}
return bounds
}
func byteSpanFromUTF16Bounds(bounds []int, offset, length int) (byteSpan, bool) {
end := offset + length
if offset < 0 || length <= 0 || end > len(bounds)-1 || bounds[offset] < 0 || bounds[end] < 0 {
return byteSpan{}, false
}
return byteSpan{start: bounds[offset], end: bounds[end]}, true
}
func isUsernameByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
@ -543,63 +683,18 @@ func tgPrivateSendResultUpdates(res domain.SendPrivateTextResult, randomID int64
}
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)
add := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
switch {
case isSystemUserID(id):
if u, ok := domain.SystemUserByID(id); ok {
users = append(users, r.tgUser(u))
}
case id == ownerUserID:
if r.deps.Users == nil {
return
}
u, err := r.deps.Users.Self(ctx, ownerUserID)
if err == nil && u.ID != 0 {
users = append(users, r.tgSelfUser(u))
}
default:
if r.deps.Users == nil {
return
}
u, found, err := r.deps.Users.ByID(ctx, ownerUserID, id)
if err == nil && found {
users = append(users, r.tgUser(u))
}
}
}
if msg.From.Type == domain.PeerTypeUser {
add(msg.From.ID)
}
if msg.Peer.Type == domain.PeerTypeUser {
add(msg.Peer.ID)
}
if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser {
add(msg.Forward.From.ID)
}
add(msg.ViaBotID)
if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser {
add(msg.ReplyTo.Peer.ID)
}
if msg.Media != nil && msg.Media.Contact != nil {
add(msg.Media.Contact.UserID)
}
// A non-min User replaces the cached peer on iOS. Keep the complete
// username vector on synchronous message echoes instead of letting this
// response regress a previously hydrated profile to the legacy scalar.
r.applyUsernamesToPeerObjects(ctx, users, nil)
return users
return r.usersForMessageUpdates(ctx, ownerUserID, []domain.Message{msg})
}
func (r *Router) usersForMessageUpdateWithPreloaded(ctx context.Context, ownerUserID int64, msg domain.Message, preloaded []domain.User) []tg.UserClass {
return r.usersForMessageUpdatesWithPreloaded(ctx, ownerUserID, []domain.Message{msg}, preloaded)
}
func (r *Router) usersForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.UserClass {
return r.usersForMessageUpdatesWithPreloaded(ctx, ownerUserID, messages, nil)
}
func (r *Router) usersForMessageUpdatesWithPreloaded(ctx context.Context, ownerUserID int64, messages []domain.Message, preloaded []domain.User) []tg.UserClass {
seen := make(map[int64]struct{}, len(messages)*2)
ids := make([]int64, 0, len(messages)*2)
addID := func(id int64) {
@ -613,29 +708,30 @@ func (r *Router) usersForMessageUpdates(ctx context.Context, ownerUserID int64,
ids = append(ids, id)
}
for _, msg := range messages {
if msg.From.Type == domain.PeerTypeUser {
addID(msg.From.ID)
}
if msg.Peer.Type == domain.PeerTypeUser {
addID(msg.Peer.ID)
}
if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser {
addID(msg.Forward.From.ID)
}
addID(msg.ViaBotID)
if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser {
addID(msg.ReplyTo.Peer.ID)
}
if msg.Media != nil && msg.Media.Contact != nil {
addID(msg.Media.Contact.UserID)
for _, id := range appendMessageUserIDs(nil, make(map[int64]struct{}), msg) {
addID(id)
}
}
if len(ids) == 0 {
return nil
}
loaded := make(map[int64]domain.User, len(ids))
if r.deps.Users != nil {
if users, err := r.deps.Users.ByIDs(ctx, ownerUserID, ids); err == nil {
for _, user := range preloaded {
if user.ID != 0 {
loaded[user.ID] = user
}
}
missing := make([]int64, 0, len(ids))
for _, id := range ids {
if isSystemUserID(id) {
continue
}
if _, ok := loaded[id]; !ok {
missing = append(missing, id)
}
}
if r.deps.Users != nil && len(missing) > 0 {
if users, err := r.deps.Users.ByIDs(ctx, ownerUserID, missing); err == nil {
for _, user := range users {
loaded[user.ID] = user
}
@ -666,6 +762,46 @@ func (r *Router) chatsForMessageUpdate(ctx context.Context, ownerUserID int64, m
return r.chatsForMessageUpdates(ctx, ownerUserID, []domain.Message{msg})
}
func appendMessageUserIDs(ids []int64, seen map[int64]struct{}, msg domain.Message) []int64 {
add := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, peer := range []domain.Peer{msg.From, msg.Peer} {
if peer.Type == domain.PeerTypeUser {
add(peer.ID)
}
}
if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser {
add(msg.Forward.From.ID)
}
add(msg.ViaBotID)
if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser {
add(msg.ReplyTo.Peer.ID)
}
if msg.Media != nil && msg.Media.Contact != nil {
add(msg.Media.Contact.UserID)
}
userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{})
collectMessagePeerRefs(msg, 0, userRefs, channelRefs)
extra := make([]int64, 0, len(userRefs))
for id := range userRefs {
extra = append(extra, id)
}
sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] })
for _, id := range extra {
add(id)
}
return ids
}
func appendMessageChannelIDs(ids []int64, seen map[int64]struct{}, msg domain.Message) []int64 {
add := func(id int64) {
if id == 0 {
@ -677,11 +813,10 @@ func appendMessageChannelIDs(ids []int64, seen map[int64]struct{}, msg domain.Me
seen[id] = struct{}{}
ids = append(ids, id)
}
if msg.From.Type == domain.PeerTypeChannel {
add(msg.From.ID)
}
if msg.Peer.Type == domain.PeerTypeChannel {
add(msg.Peer.ID)
for _, peer := range []domain.Peer{msg.From, msg.Peer} {
if peer.Type == domain.PeerTypeChannel {
add(peer.ID)
}
}
if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel {
add(msg.Forward.From.ID)
@ -689,6 +824,17 @@ func appendMessageChannelIDs(ids []int64, seen map[int64]struct{}, msg domain.Me
if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeChannel {
add(msg.ReplyTo.Peer.ID)
}
userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{})
collectMessagePeerRefs(msg, 0, userRefs, channelRefs)
extra := make([]int64, 0, len(channelRefs))
for id := range channelRefs {
extra = append(extra, id)
}
sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] })
for _, id := range extra {
add(id)
}
return ids
}
@ -744,9 +890,13 @@ func (r *Router) mentionUserIDsFromDomain(ctx context.Context, currentUserID int
}
}
if identity, ok := r.deps.Users.(UserIdentityService); ok && identity != nil {
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) {
blocked := mentionScanBlockedSpansFromDomainEntities(message, entities)
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out), blocked) {
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
if err != nil {
if isMentionResolveMiss(err) {
continue
}
break
}
if found {

View file

@ -17,16 +17,20 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
sender := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Sender"}
recipient := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Recipient"}
messages := &captureMessages{}
dialogs := &captureDialogs{}
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{sender.ID: sender, recipient.ID: recipient}}}
metrics := &captureRPCMetrics{}
r := New(Config{}, Deps{
Messages: messages,
Users: mapUsersService{users: map[int64]domain.User{sender.ID: sender, recipient.ID: recipient}},
Dialogs: dialogs,
Users: users,
Metrics: metrics,
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
Message: "hello",
RandomID: 123456,
Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
Message: "hello",
RandomID: 123456,
ClearDraft: true,
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 5},
&tg.MessageEntityFormattedDate{Offset: 6, Length: 8, Date: 1773436800, ShortDate: true, ShortTime: true},
@ -82,6 +86,40 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
if metrics.messageSend != 1 || metrics.messageSendErr != nil {
t.Fatalf("metrics send=%d err=%v, want one successful send", metrics.messageSend, metrics.messageSendErr)
}
if users.byIDsCalls != 1 || users.byIDCalls != 0 || users.selfCalls != 0 {
t.Fatalf("send user lookups byIDs/byID/self = %d/%d/%d, want one shared batch projection", users.byIDsCalls, users.byIDCalls, users.selfCalls)
}
}
func TestUsersForMessageUpdateUsesOneBatchLookup(t *testing.T) {
const ownerID int64 = 1000000001
const peerID int64 = 1000000002
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{
ownerID: {ID: ownerID, FirstName: "Owner"},
peerID: {ID: peerID, FirstName: "Peer"},
}}}
r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
got := r.usersForMessageUpdate(context.Background(), ownerID, domain.Message{
OwnerUserID: ownerID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
})
if users.byIDsCalls != 1 || users.selfCalls != 0 || users.byIDCalls != 0 {
t.Fatalf("user lookups byIDs/self/byID = %d/%d/%d, want 1/0/0", users.byIDsCalls, users.selfCalls, users.byIDCalls)
}
if len(got) != 2 {
t.Fatalf("users = %+v, want owner and peer", got)
}
owner, ok := got[0].(*tg.User)
if !ok || owner.ID != ownerID || !owner.Self {
t.Fatalf("first user = %+v, want self owner %d", got[0], ownerID)
}
peer, ok := got[1].(*tg.User)
if !ok || peer.ID != peerID || peer.Self {
t.Fatalf("second user = %+v, want non-self peer %d", got[1], peerID)
}
}
func TestMessagesSendMessageRateLimitReturnsFloodWait(t *testing.T) {

View file

@ -104,6 +104,68 @@ func TestSendChannelMessageUsesSameFutureWebPageDeadline(t *testing.T) {
}
}
func TestSendChannelMessageAllowsAtPathSegmentURL(t *testing.T) {
ctx := context.Background()
r, owner, channel := newRichChannelTestRouter(t)
const message = "https://github.com/@11"
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: message,
RandomID: 5107,
})
if err != nil {
t.Fatalf("send channel message with @ path segment URL: %v", err)
}
msg := newMessageFromUpdates(t, updates)
if msg.Message != message {
t.Fatalf("message = %q, want %q", msg.Message, message)
}
var urls, mentions int
for _, entity := range msg.Entities {
switch entity.(type) {
case *tg.MessageEntityURL:
urls++
case *tg.MessageEntityMention:
mentions++
}
}
if urls != 1 || mentions != 0 {
t.Fatalf("entities url=%d mention=%d, want url=1 mention=0: %#v", urls, mentions, msg.Entities)
}
}
func TestSendChannelMessageAllowsBareDomainAtPathSegmentURL(t *testing.T) {
ctx := context.Background()
r, owner, channel := newRichChannelTestRouter(t)
const message = "github.com/@alice"
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: message,
RandomID: 5108,
})
if err != nil {
t.Fatalf("send channel message with bare @ path segment URL: %v", err)
}
msg := newMessageFromUpdates(t, updates)
if msg.Message != message {
t.Fatalf("message = %q, want %q", msg.Message, message)
}
var urls, mentions int
for _, entity := range msg.Entities {
switch entity.(type) {
case *tg.MessageEntityURL:
urls++
case *tg.MessageEntityMention:
mentions++
}
}
if urls != 1 || mentions != 0 {
t.Fatalf("entities url=%d mention=%d, want url=1 mention=0: %#v", urls, mentions, msg.Entities)
}
}
// TestSendMessageAttachesCachedDoneCard 验证URL 已缓存解析时,发送 echo 直接带 done 卡片
// (非 pending——官方行为TDesktop 据此立即渲染、不依赖异步换卡。
func TestSendMessageAttachesCachedDoneCard(t *testing.T) {

View file

@ -65,7 +65,7 @@ func (r *Router) onMessagesToggleSuggestedPostApproval(ctx context.Context, req
if !result.Duplicate {
r.enqueueSuggestedPostApprovalFanout(ctx, userID, result)
}
return r.suggestedPostApprovalUpdates(ctx, userID, result), nil
return r.suggestedPostApprovalUpdatesStrict(ctx, userID, result)
}
func suggestedPostApprovalErr(err error) error {

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"errors"
"testing"
"time"
@ -17,6 +18,33 @@ import (
"telesrv/internal/store/memory"
)
func TestSuggestedPostApprovalUpdatesFailsClosedOnIncompleteUserEnvelope(t *testing.T) {
const (
viewerID = int64(1000000401)
savedPeerID = int64(1000000402)
monoforumID = int64(1000000491)
)
router := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
viewerID: {ID: viewerID, FirstName: "viewer"},
}}}, zaptest.NewLogger(t), clock.System)
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: savedPeerID}
message := domain.ChannelMessage{
ChannelID: monoforumID, ID: 1, SenderUserID: viewerID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, SavedPeer: savedPeer,
Date: 1700000800,
}
result := domain.ToggleSuggestedPostApprovalResult{
Monoforum: domain.Channel{ID: monoforumID, Monoforum: true}, SavedPeer: savedPeer,
OriginalMessage: message,
OriginalEvent: domain.ChannelUpdateEvent{ChannelID: monoforumID, Type: domain.ChannelUpdateEditMessage, Pts: 3, PtsCount: 1, Message: message},
}
updates, err := router.suggestedPostApprovalUpdatesStrict(context.Background(), viewerID, result)
if !errors.Is(err, ErrDurableUserProjectionIncomplete) || updates != nil {
t.Fatalf("suggested-post strict envelope = %T, %v; want nil ErrDurableUserProjectionIncomplete", updates, err)
}
}
func TestMessagesToggleSuggestedPostApprovalRegisteredAndProjectsLifecycle(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()

View file

@ -8,7 +8,16 @@ import (
"telesrv/internal/domain"
)
func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult) *tg.Updates {
func (r *Router) suggestedPostApprovalUpdatesStrict(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult) (*tg.Updates, error) {
return r.suggestedPostApprovalUpdatesWithPeerCacheAndOverlaysStrict(ctx, viewerUserID, result, nil, nil)
}
func (r *Router) suggestedPostApprovalUpdatesWithPeerCacheAndOverlays(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) *tg.Updates {
updates, _ := r.suggestedPostApprovalUpdatesWithPeerCacheAndOverlaysStrict(ctx, viewerUserID, result, cache, overlays)
return updates
}
func (r *Router) suggestedPostApprovalUpdatesWithPeerCacheAndOverlaysStrict(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) (*tg.Updates, error) {
updates := make([]tg.UpdateClass, 0, 4)
if result.OriginalEvent.Pts > 0 {
if update := tgChannelUpdate(viewerUserID, result.OriginalEvent); update != nil {
@ -39,12 +48,24 @@ func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID
if result.Published != nil {
messages = append(messages, result.Published.Message)
}
if cache == nil {
cache = newViewerPeerCache(r)
}
if overlays == nil {
ids := monoforumSubscriberUserIDs([]domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages)
overlays = r.loadMonoforumPeerOverlays(ctx, monoforumProjectionPeers(result.Monoforum.ID, result.Parent.ID, ids))
}
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, viewerUserID, []domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages, cache, overlays)
if err != nil {
return nil, err
}
applyMonoforumPeerOverlays(nil, chats, overlays)
return &tg.Updates{
Updates: updates,
Chats: chats,
Users: r.monoforumSubscriberUsers(ctx, viewerUserID, []domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages),
Users: users,
Date: int(r.clock.Now().Unix()),
}
}, nil
}
func (r *Router) enqueueSuggestedPostApprovalFanout(ctx context.Context, originUserID int64, result domain.ToggleSuggestedPostApprovalResult) {
@ -52,9 +73,29 @@ func (r *Router) enqueueSuggestedPostApprovalFanout(ctx context.Context, originU
monoOnly.Published = nil
nudge := max(result.OriginalEvent.Pts, result.ServiceEvent.Pts)
if nudge > 0 {
r.enqueueChannelFanout(ctx, channelFanoutExplicit, originUserID, result.Monoforum.ID, nudge, result.Recipients, func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.suggestedPostApprovalUpdates(bgCtx, viewerUserID, monoOnly)
})
messages := make([]domain.ChannelMessage, 0, 2)
if monoOnly.OriginalMessage.ID != 0 {
messages = append(messages, monoOnly.OriginalMessage)
}
if monoOnly.ServiceMessage.ID != 0 {
messages = append(messages, monoOnly.ServiceMessage)
}
ownerIDs := monoforumSubscriberUserIDs([]domain.MonoforumDialog{{SavedPeer: monoOnly.SavedPeer}}, messages)
fanoutCache := newViewerPeerCache(r)
projectionPeers := monoforumProjectionPeers(monoOnly.Monoforum.ID, monoOnly.Parent.ID, ownerIDs)
var overlays *monoforumPeerOverlays
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, result.Monoforum.ID, nudge, result.Recipients,
0,
func(bgCtx context.Context, viewers []int64) bool {
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
return false
}
overlays = r.loadMonoforumPeerOverlays(bgCtx, projectionPeers)
return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.suggestedPostApprovalUpdatesWithPeerCacheAndOverlays(bgCtx, viewerUserID, monoOnly, fanoutCache, overlays)
})
}
if result.Published != nil && result.Published.Event.Pts > 0 {
r.enqueueChannelMessageFanout(ctx, originUserID, *result.Published, nil)

View file

@ -9,6 +9,11 @@ type Metrics interface {
OutboxClaimed(count int)
OutboxDelivered(d time.Duration)
OutboxFailed(err error)
PresenceLastSeenBatch(count int, d time.Duration, err error)
PresenceLastSeenSubmitted()
PresenceLastSeenPending(delta int)
PresenceLastSeenOverflow()
PresenceLastSeenDrainDropped(count int)
}
// NopMetrics 是 Metrics 的空实现。
@ -23,3 +28,13 @@ func (NopMetrics) OutboxClaimed(int) {}
func (NopMetrics) OutboxDelivered(time.Duration) {}
func (NopMetrics) OutboxFailed(error) {}
func (NopMetrics) PresenceLastSeenBatch(int, time.Duration, error) {}
func (NopMetrics) PresenceLastSeenSubmitted() {}
func (NopMetrics) PresenceLastSeenPending(int) {}
func (NopMetrics) PresenceLastSeenOverflow() {}
func (NopMetrics) PresenceLastSeenDrainDropped(int) {}

View file

@ -57,7 +57,8 @@ func TestUserModerationFlagsPushStandardNonPTSUpdate(t *testing.T) {
audience: []int64{targetID, onlineViewerID, offlineViewerID},
}
sessions := &captureSessions{onlineUserIDs: []int64{targetID, onlineViewerID}}
r := New(Config{}, Deps{Users: users, Sessions: sessions}, zap.NewNop(), clock.System)
dialogs := &captureDialogs{}
r := New(Config{}, Deps{Users: users, Sessions: sessions, Dialogs: dialogs}, zap.NewNop(), clock.System)
if err := r.NotifyUserModerationFlagsChanged(context.Background(), domain.User{
ID: targetID, FirstName: "Flagged", Scam: true,
@ -71,6 +72,15 @@ func TestUserModerationFlagsPushStandardNonPTSUpdate(t *testing.T) {
if len(users.viewers) != 2 || users.viewers[0] != targetID || users.viewers[1] != onlineViewerID {
t.Fatalf("projected viewers = %v", users.viewers)
}
if len(dialogs.invalidatedDialogs) != 3 {
t.Fatalf("dialog hash invalidations = %+v, want online and offline audience", dialogs.invalidatedDialogs)
}
for i, viewerID := range []int64{targetID, onlineViewerID, offlineViewerID} {
got := dialogs.invalidatedDialogs[i]
if got.userID != viewerID || got.peer != (domain.Peer{Type: domain.PeerTypeUser, ID: targetID}) {
t.Fatalf("dialog hash invalidation[%d] = %+v", i, got)
}
}
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("updates = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"errors"
"fmt"
"sort"
"sync"
"time"
@ -16,7 +17,7 @@ import (
)
const (
defaultOutboxBatch = 100
defaultOutboxBatch = 10
defaultOutboxInterval = 200 * time.Millisecond
defaultOutboxWorkers = 4
// outboxLogicalShards 是稳定 user→lane 哈希空间。它不随运行时 worker 数变化,
@ -31,6 +32,9 @@ const (
var (
errMissingOutboxEvent = errors.New("missing outbox update event")
errOutboxUpdateBuilderMissing = errors.New("outbox update builder is required")
errOutboxUpdateBuilderCount = errors.New("outbox update builder returned mismatched count")
errOutboxUpdateBuilderEmpty = errors.New("outbox update builder returned a nil non-noop update")
errInvalidOutboxExclusionPair = errors.New("outbox exclusion requires both raw auth key and session id")
)
@ -60,7 +64,7 @@ type OutboxUpdateRequest struct {
}
// OutboxUpdateBuilder 按接收者视角批量把 domain.UpdateEvent 转为 TL updates。
type OutboxUpdateBuilder func(ctx context.Context, requests []OutboxUpdateRequest) []*tg.Updates
type OutboxUpdateBuilder func(ctx context.Context, requests []OutboxUpdateRequest) ([]*tg.Updates, error)
// WithOutboxUpdateBuilder 注入按接收者视角的批量 updates 构建器。
func WithOutboxUpdateBuilder(builder OutboxUpdateBuilder) OutboxOption {
@ -333,7 +337,24 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp
ready = append(ready, outboxDispatchReady{item: item})
requests = append(requests, OutboxUpdateRequest{TargetUserID: item.TargetUserID, Event: event})
}
builtUpdates := d.buildOutboxUpdates(ctx, requests)
builtUpdates, err := d.buildOutboxUpdates(ctx, requests)
if err != nil {
// 聚合构建失败不代表每个 durable row 都坏了。复用本批已经加载的 event
// 做 singleton 隔离,避免某一条坏数据或批量容量错误污染无关用户;同一用户
// 的 lane head 一旦失败,后续 pts 仍不得越过。
d.log.Warn("batch build dispatch outbox updates; isolating items", zap.Error(err))
clear(blockedUsers)
for i, entry := range ready {
item := entry.item
if _, blocked := blockedUsers[item.TargetUserID]; blocked {
continue
}
if !d.dispatchPreparedItem(ctx, item, requests[i].Event, time.Now()) {
blockedUsers[item.TargetUserID] = struct{}{}
}
}
return
}
delivered := make([]store.DispatchOutboxItem, 0, len(items))
clear(blockedUsers)
for i, entry := range ready {
@ -395,7 +416,17 @@ func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.Dispatch
d.markDispatchFailed(ctx, item, errMissingOutboxEvent)
return false
}
update := d.buildOutboxUpdate(ctx, item, events[0])
return d.dispatchPreparedItem(ctx, item, events[0], start)
}
// dispatchPreparedItem 构建并投递一条已经加载、且 exclusion pair 已验证的 event。
// 批量构建隔离路径调用它时不会再次读取 event store。
func (d *OutboxDispatcher) dispatchPreparedItem(ctx context.Context, item store.DispatchOutboxItem, event domain.UpdateEvent, start time.Time) bool {
update, err := d.buildOutboxUpdate(ctx, item, event)
if err != nil {
d.markDispatchFailed(ctx, item, err)
return false
}
if update == 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))
@ -433,33 +464,38 @@ func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.Dispatch
return true
}
func (d *OutboxDispatcher) buildOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, event domain.UpdateEvent) *tg.Updates {
updates := d.buildOutboxUpdates(ctx, []OutboxUpdateRequest{{TargetUserID: item.TargetUserID, Event: event}})
if len(updates) == 0 {
return nil
func (d *OutboxDispatcher) buildOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, event domain.UpdateEvent) (*tg.Updates, error) {
updates, err := d.buildOutboxUpdates(ctx, []OutboxUpdateRequest{{TargetUserID: item.TargetUserID, Event: event}})
if err != nil {
return nil, err
}
return updates[0]
if len(updates) == 0 {
return nil, nil
}
return updates[0], nil
}
func (d *OutboxDispatcher) buildOutboxUpdates(ctx context.Context, requests []OutboxUpdateRequest) []*tg.Updates {
func (d *OutboxDispatcher) buildOutboxUpdates(ctx context.Context, requests []OutboxUpdateRequest) ([]*tg.Updates, error) {
out := make([]*tg.Updates, len(requests))
if len(requests) == 0 {
return out
return out, nil
}
if d.updateBuilder != nil {
built := d.updateBuilder(ctx, requests)
if len(built) == len(requests) {
return built
if d.updateBuilder == nil {
return nil, errOutboxUpdateBuilderMissing
}
built, err := d.updateBuilder(ctx, requests)
if err != nil {
return nil, err
}
if len(built) != len(requests) {
return nil, fmt.Errorf("%w: got %d want %d", errOutboxUpdateBuilderCount, len(built), len(requests))
}
for i := range built {
if built[i] == nil && requests[i].Event.Type != domain.UpdateEventNoop {
return nil, fmt.Errorf("%w: index=%d user_id=%d pts=%d event_type=%s", errOutboxUpdateBuilderEmpty, i, requests[i].TargetUserID, requests[i].Event.Pts, requests[i].Event.Type)
}
d.log.Warn("outbox update builder returned mismatched count",
zap.Int("requests", len(requests)),
zap.Int("updates", len(built)),
)
}
for i, req := range requests {
out[i] = tgUpdateForOutboxEvent(req.Event)
}
return out
return built, nil
}
// pushOutboxUpdate 投递一条 outbox update返回 (送达的在线 session 数, 是否可重试, err)。
@ -527,6 +563,9 @@ func tgUpdateForOutboxEventForViewer(event domain.UpdateEvent, viewerUserID int6
}
switch event.Type {
case domain.UpdateEventNewMessage:
if event.Message.Deleted {
return tgDeletedPrivateMessageOutboxUpdate(event)
}
return tgPrivateMessageUpdates(event, event.Message, 0, false, tgUsersForViewer(viewerUserID, event.Users), tgChannels(viewerUserID, event.Channels))
case domain.UpdateEventReadHistoryInbox, domain.UpdateEventReadHistoryOutbox:
var update tg.UpdateClass
@ -569,6 +608,29 @@ func tgUpdateForOutboxEventForViewer(event domain.UpdateEvent, viewerUserID int6
}
}
func tgDeletedPrivateMessageOutboxUpdate(event domain.UpdateEvent) *tg.Updates {
if event.Pts <= 0 || event.PtsCount <= 0 {
return nil
}
ids := []int{}
if event.Message.ID > 0 && event.Message.ID <= domain.MaxMessageBoxID {
ids = append(ids, event.Message.ID)
}
date := event.Date
if date == 0 {
date = event.Message.Date
}
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateDeleteMessages{
Messages: ids,
Pts: event.Pts,
PtsCount: event.PtsCount,
}},
Date: date,
Seq: 0,
}
}
// appendAuxPtsBookkeeping 给"占账号 pts 但 TL update 不带 pts"的事件附一条
// 空 updateDeleteMessages客户端按 pts/pts_count 推进本地水位且不产生任何
// 可见变化。没有它,客户端水位停在事件前,下一条带 pts 的更新会被判为空洞。

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