diff --git a/internal/rpc/messages_saved_dialogs.go b/internal/rpc/messages_saved_dialogs.go index 91cf79b4..13d068ff 100644 --- a/internal/rpc/messages_saved_dialogs.go +++ b/internal/rpc/messages_saved_dialogs.go @@ -21,9 +21,13 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { return nil, err } else if hasParent { - if req.Limit < 0 || req.OffsetID < 0 { + if req.Limit < 0 { return nil, limitInvalidErr() } + offsetID := req.OffsetID + if offsetID < 0 { + offsetID = 0 + } mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent) if err != nil { return nil, err @@ -38,15 +42,23 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages }, nil } // parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。 - return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, req.OffsetID) + return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, offsetID) } - if req.Limit < 0 || req.OffsetID < 0 || req.OffsetDate < 0 { + if req.Limit < 0 { return nil, limitInvalidErr() } + offsetID := req.OffsetID + if offsetID < 0 { + offsetID = 0 + } + offsetDate := req.OffsetDate + if offsetDate < 0 { + offsetDate = 0 + } filter := domain.SavedDialogsFilter{ ExcludePinned: req.ExcludePinned, - OffsetID: req.OffsetID, - OffsetDate: req.OffsetDate, + OffsetID: offsetID, + OffsetDate: offsetDate, Limit: req.Limit, } if req.OffsetPeer != nil { diff --git a/internal/rpc/messages_saved_dialogs_rpc_test.go b/internal/rpc/messages_saved_dialogs_rpc_test.go index 4f75782c..345dca42 100644 --- a/internal/rpc/messages_saved_dialogs_rpc_test.go +++ b/internal/rpc/messages_saved_dialogs_rpc_test.go @@ -7,6 +7,7 @@ import ( "github.com/gotd/td/bin" "github.com/gotd/td/clock" "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" "go.uber.org/zap/zaptest" appcontacts "telesrv/internal/app/contacts" @@ -252,6 +253,34 @@ func TestMessagesGetSavedDialogsPagination(t *testing.T) { } } +// TestMessagesGetSavedDialogsNegativePendingOffset 验证 DrKLO Android self Saved +// Messages 分组里出现本地 pending 负消息 id 时,下一页请求不会被误判为 +// LIMIT_INVALID。服务端持久化 top_message 仍保持正数;负 offset 仅是客户端本地 +// 游标噪声,按首页/无 offset 处理。 +func TestMessagesGetSavedDialogsNegativePendingOffset(t *testing.T) { + ctx := context.Background() + r, _, alice, _, _, _ := savedDialogsFixture(t) + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{ + Limit: 20, + OffsetID: -1001, + OffsetDate: -1, + OffsetPeer: &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("negative pending offset: %v", err) + } + dialogs, _, _, _, full := savedDialogPage(t, res) + if !full || len(dialogs) != 2 { + t.Fatalf("negative pending offset page = %d full %v, want first page 2 full", len(dialogs), full) + } + + _, err = r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: -1}) + if !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("negative limit err = %v, want LIMIT_INVALID", err) + } +} + // TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中 // 返回 savedDialogsNotModified{count}。 func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) { diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index c4196e04..f81a1c4e 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -77,6 +77,13 @@ func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetA now := int(r.clock.Now().Unix()) var cursor domain.StoryListCursor if next { + if digest, ok := storyAllStoriesDigestFromCompleteState(requestState, hidden); ok { + list := domain.StoryList{Hidden: hidden, Count: digest.Count, State: requestState} + if r.deps.Stories == nil || userID == 0 { + return tgStoriesAllStories(userID, list), nil + } + return r.tgStoriesAllStories(ctx, userID, list), nil + } cursor, err = storyAllStoriesCursorFromState(requestState, hidden) if err != nil { return nil, offsetInvalidErr() @@ -135,7 +142,16 @@ func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) er next := storyAllStoriesNext(req) hidden := storyAllStoriesHidden(req) if next { - if !hasState || state == "" || !storyAllStoriesCursorStateToken(state) { + if !hasState || state == "" { + return offsetInvalidErr() + } + if storyAllStoriesCompleteState(state) { + if !storyAllStoriesCompleteStateForHidden(state, hidden) { + return offsetInvalidErr() + } + return nil + } + if !storyAllStoriesCursorStateToken(state) { return offsetInvalidErr() } if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil { @@ -172,6 +188,22 @@ func storyAllStoriesCompleteState(state string) bool { return strings.HasPrefix(state, "ts1:") } +func storyAllStoriesDigestFromCompleteState(state string, hidden bool) (domain.StoryListDigest, bool) { + if !storyAllStoriesCompleteStateForHidden(state, hidden) { + return domain.StoryListDigest{}, false + } + parts := strings.Split(state, ":") + count, err := strconv.Atoi(parts[2]) + if err != nil || count < 0 { + return domain.StoryListDigest{}, false + } + hash, err := strconv.ParseUint(parts[3], 16, 64) + if err != nil { + return domain.StoryListDigest{}, false + } + return domain.StoryListDigest{Count: count, Hash: hash}, true +} + func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool { parts := strings.Split(state, ":") if len(parts) != 4 || parts[0] != "ts1" { diff --git a/internal/rpc/stories_rpc_test.go b/internal/rpc/stories_rpc_test.go index ba083e11..0cda92d5 100644 --- a/internal/rpc/stories_rpc_test.go +++ b/internal/rpc/stories_rpc_test.go @@ -298,8 +298,16 @@ func TestStoriesGetAllStoriesPaginatesByPeerState(t *testing.T) { finalAsCursor := &tg.StoriesGetAllStoriesRequest{} finalAsCursor.SetState(next.State) finalAsCursor.SetNext(true) - if _, err := r.onStoriesGetAllStories(ctx, finalAsCursor); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { - t.Fatalf("final state as cursor err = %v, want OFFSET_INVALID", err) + finalAsCursorClass, err := r.onStoriesGetAllStories(reqCtx, finalAsCursor) + if err != nil { + t.Fatalf("final state as cursor: %v", err) + } + finalAsCursorPage, ok := finalAsCursorClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("final state as cursor response = %T, want stories.allStories terminal page", finalAsCursorClass) + } + if finalAsCursorPage.HasMore || len(finalAsCursorPage.PeerStories) != 0 || finalAsCursorPage.Count != domain.MaxStoryListLimit+1 || finalAsCursorPage.State != next.State { + t.Fatalf("final state as cursor page = %+v, want empty terminal page preserving request count/state", finalAsCursorPage) } } diff --git a/internal/store/postgres/message_send_integration_test.go b/internal/store/postgres/message_send_integration_test.go index 6ce71c83..773773af 100644 --- a/internal/store/postgres/message_send_integration_test.go +++ b/internal/store/postgres/message_send_integration_test.go @@ -204,6 +204,115 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { assertWebViewData("recipient event", events[0].Message) } +func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + caller := createTestUser(t, ctx, users, "+1666"+suffix+"41", "CallFirstSender", "") + callee := createTestUser(t, ctx, users, "+1666"+suffix+"42", "CallFirstRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{caller.ID, callee.ID}) + }) + + const callID int64 = 0x1020304050607080 + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: caller.ID, + RecipientUserID: callee.ID, + RandomID: 4041001, + Date: 1700000410, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionPhoneCall, + Call: &domain.MessagePhoneCallAction{ + CallID: callID, + Reason: string(domain.PhoneCallDiscardReasonMissed), + }, + }, + }, + }) + if err != nil { + t.Fatalf("SendPrivateText phone call service: %v", err) + } + if sent.SenderMessage.ID != 1 || sent.RecipientMessage.ID != 1 { + t.Fatalf("first phone call boxes = sender %d recipient %d, want both first message", sent.SenderMessage.ID, sent.RecipientMessage.ID) + } + + assertPhoneCallMessage := func(name string, msg domain.Message, ownerID, peerID, fromID int64, out bool) { + t.Helper() + if msg.OwnerUserID != ownerID || msg.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) || + msg.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) || msg.Out != out { + t.Fatalf("%s identity = owner %d peer %+v from %+v out %v", name, msg.OwnerUserID, msg.Peer, msg.From, msg.Out) + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindService || + msg.Media.ServiceAction == nil || msg.Media.ServiceAction.Kind != domain.MessageServiceActionPhoneCall || + msg.Media.ServiceAction.Call == nil { + t.Fatalf("%s media = %+v, want phone_call service action", name, msg.Media) + } + call := msg.Media.ServiceAction.Call + if call.CallID != callID || call.Reason != string(domain.PhoneCallDiscardReasonMissed) || call.Duration != 0 { + t.Fatalf("%s phone call action = %+v", name, call) + } + } + assertPhoneCallMessage("sender box", sent.SenderMessage, caller.ID, callee.ID, caller.ID, true) + assertPhoneCallMessage("recipient box", sent.RecipientMessage, callee.ID, caller.ID, caller.ID, false) + + dialogs := NewDialogStore(pool) + calleeDialogs, err := dialogs.ListByUser(ctx, callee.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("callee dialogs: %v", err) + } + if len(calleeDialogs.Dialogs) != 1 || calleeDialogs.Dialogs[0].TopMessage != sent.RecipientMessage.ID || + calleeDialogs.Dialogs[0].UnreadCount != 1 || calleeDialogs.Dialogs[0].ReadInboxMaxID != 0 { + t.Fatalf("callee dialogs = %+v, want unread first call service as top", calleeDialogs.Dialogs) + } + if len(calleeDialogs.Messages) != 1 { + t.Fatalf("callee dialog messages = %+v, want top message payload", calleeDialogs.Messages) + } + assertPhoneCallMessage("callee dialog top", calleeDialogs.Messages[0], callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(calleeDialogs.Users, caller.ID); !ok { + t.Fatalf("callee dialog users = %+v, want caller snapshot", calleeDialogs.Users) + } + + calleeHistory, err := messages.ListByUser(ctx, callee.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: caller.ID}, + Limit: 10, + }) + if err != nil || len(calleeHistory.Messages) != 1 { + t.Fatalf("callee history = %+v err=%v, want one first call service", calleeHistory, err) + } + assertPhoneCallMessage("callee history", calleeHistory.Messages[0], callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(calleeHistory.Users, caller.ID); !ok { + t.Fatalf("callee history users = %+v, want caller snapshot", calleeHistory.Users) + } + + updates := NewUpdateEventStore(pool) + calleeEvents, err := updates.ListAfter(ctx, callee.ID, 0, 10) + if err != nil || len(calleeEvents) != 1 { + t.Fatalf("callee events = %+v err=%v, want one new_message", calleeEvents, err) + } + if calleeEvents[0].Type != domain.UpdateEventNewMessage || calleeEvents[0].Pts != sent.RecipientMessage.Pts { + t.Fatalf("callee event = %+v, want new_message pts %d", calleeEvents[0], sent.RecipientMessage.Pts) + } + assertPhoneCallMessage("callee difference event", calleeEvents[0].Message, callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(calleeEvents[0].Users, caller.ID); !ok { + t.Fatalf("callee event users = %+v, want caller snapshot", calleeEvents[0].Users) + } + + batch, err := updates.BatchByCursor(ctx, []store.EventCursor{{UserID: callee.ID, Pts: calleeEvents[0].Pts}}) + if err != nil || len(batch) != 1 { + t.Fatalf("callee batch events = %+v err=%v, want one dispatch event", batch, err) + } + assertPhoneCallMessage("callee dispatch event", batch[0].Message, callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(batch[0].Users, caller.ID); !ok { + t.Fatalf("callee batch users = %+v, want caller snapshot", batch[0].Users) + } +} + func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) { pool := testPool(t) ctx := context.Background()