merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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、也没有
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelEditMessageFanoutInvokesPrefetch:enqueueChannelEditMessageFanout 在逐 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 {
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 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{
|
||||
|
|
|
|||
97
internal/rpc/channels_create_response_rpc_test.go
Normal file
97
internal/rpc/channels_create_response_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
50
internal/rpc/client_regressions_test.go
Normal file
50
internal/rpc/client_regressions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
233
internal/rpc/difference_projection_strict_test.go
Normal file
233
internal/rpc/difference_projection_strict_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
50
internal/rpc/dispatch_context_logging_test.go
Normal file
50
internal/rpc/dispatch_context_logging_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=0):participant 所有设备(含离线)可见。
|
||||
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()))
|
||||
// 推发起方全部在线设备 encryptedChat(GAOrB=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)
|
||||
// 仅推发起方绑定设备 encryptedChat(GAOrB=g_b, key_fingerprint)。
|
||||
r.pushUpdateEncryption(ctx, chat.AdminUserID, chat.AdminAuthKeyID, chat, "secret chat accepted")
|
||||
r.pushAcceptedLoserDiscarded(ctx, chat, now)
|
||||
// 接受方同步收 encryptedChat(GAOrB=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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
// 推送给发起方:encryptedChat,GAOrB = 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{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestSendEncryptedFileFlow:sendEncryptedFile 铸造 EncryptedFile、随消息投递、返回
|
||||
|
|
@ -71,13 +75,70 @@ func TestUploadEncryptedFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestEncryptedFileLocationKey:inputEncryptedFileLocation → "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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 handler(P1)。服务端是盲中继: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...)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
99
internal/rpc/help_app_update.go
Normal file
99
internal/rpc/help_app_update.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
66
internal/rpc/help_app_update_test.go
Normal file
66
internal/rpc/help_app_update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}}},
|
||||
}}},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
204
internal/rpc/messages_getmessages_trace.go
Normal file
204
internal/rpc/messages_getmessages_trace.go
Normal 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...)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 只看自己的子会话。
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue