From c18f773701959afe2644dd49b278316964034a76 Mon Sep 17 00:00:00 2001 From: A Date: Fri, 17 Jul 2026 17:00:56 +0800 Subject: [PATCH] fix: sync rich-only private messages --- internal/domain/message.go | 7 ++ .../rpc/messages_rich_message_rpc_test.go | 81 +++++++++++++++++-- internal/rpc/messages_send.go | 2 + internal/rpc/send_media.go | 1 + internal/store/memory/message_send.go | 3 + internal/store/memory/message_test.go | 29 +++++++ .../message_rich_message_integration_test.go | 14 +++- internal/store/postgres/message_send.go | 4 +- 8 files changed, 132 insertions(+), 9 deletions(-) diff --git a/internal/domain/message.go b/internal/domain/message.go index 5c74ad08..c35efca7 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -289,6 +289,13 @@ type SendPrivateTextRequest struct { RichMessage *MessageRichMessage } +// HasContent reports whether the command contains a client-visible message payload. +// TDesktop rich-message posting intentionally leaves Message empty, so every send +// boundary must treat text, media, and rich_message as equivalent content sources. +func (r SendPrivateTextRequest) HasContent() bool { + return r.Message != "" || !r.Media.IsZero() || !r.RichMessage.IsZero() +} + // PrivateSendReplayRequest identifies one already-committed private send without carrying any // mutable or resolver-derived message fields. The fingerprint is computed at the original // request boundary and must be a complete SHA-256 value. diff --git a/internal/rpc/messages_rich_message_rpc_test.go b/internal/rpc/messages_rich_message_rpc_test.go index d53974e1..e69adf12 100644 --- a/internal/rpc/messages_rich_message_rpc_test.go +++ b/internal/rpc/messages_rich_message_rpc_test.go @@ -27,6 +27,51 @@ func richTextBlocksWith(title, paragraph string) []tg.PageBlockClass { } } +func richHeadingTableBlocks() []tg.PageBlockClass { + return []tg.PageBlockClass{ + &tg.PageBlockHeading2{Text: &tg.TextPlain{Text: "Quarterly results"}}, + &tg.PageBlockTable{ + Bordered: true, + Striped: true, + Title: &tg.TextPlain{Text: "Revenue"}, + Rows: []tg.PageTableRow{ + {Cells: []tg.PageTableCell{ + {Header: true, Text: &tg.TextPlain{Text: "Quarter"}}, + {Header: true, Text: &tg.TextPlain{Text: "Amount"}}, + }}, + {Cells: []tg.PageTableCell{ + {Text: &tg.TextPlain{Text: "Q1"}}, + {AlignRight: true, Text: &tg.TextPlain{Text: "100"}}, + }}, + }, + }, + } +} + +func assertRichHeadingTableBlocks(t *testing.T, label string, rich tg.RichMessage) { + t.Helper() + if len(rich.Blocks) != 2 { + t.Fatalf("%s: blocks = %d, want heading and table", label, len(rich.Blocks)) + } + heading, ok := rich.Blocks[0].(*tg.PageBlockHeading2) + if !ok { + t.Fatalf("%s: block[0] = %T, want *tg.PageBlockHeading2", label, rich.Blocks[0]) + } + if text, ok := heading.Text.(*tg.TextPlain); !ok || text.Text != "Quarterly results" { + t.Fatalf("%s: heading text = %+v", label, heading.Text) + } + table, ok := rich.Blocks[1].(*tg.PageBlockTable) + if !ok { + t.Fatalf("%s: block[1] = %T, want *tg.PageBlockTable", label, rich.Blocks[1]) + } + if !table.Bordered || !table.Striped || len(table.Rows) != 2 || len(table.Rows[0].Cells) != 2 { + t.Fatalf("%s: table shape = %+v", label, table) + } + if !table.Rows[0].Cells[0].Header { + t.Fatalf("%s: first table cell lost header flag", label) + } +} + func richEmptyCaption() tg.PageCaption { return tg.PageCaption{ Text: &tg.TextEmpty{}, @@ -417,9 +462,9 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { assertRichTextBlocks(t, "getRichMessage", rich) } -// TestSendMessageRichOnlyTextBlocksRoundTrip 覆盖 TDesktop rich editor 的真实发送形态: -// messages.sendMessage 带 f_rich_message,但 message:string 为空。 -func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) { +// TestSendMessageRichOnlyHeadingTableRoundTrip 覆盖 TDesktop rich editor 的真实发送形态: +// messages.sendMessage 带 f_rich_message(标题与表格 blocks),但 message:string 为空。 +func TestSendMessageRichOnlyHeadingTableRoundTrip(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) @@ -427,8 +472,7 @@ func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) { Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, RandomID: 7101, RichMessage: &tg.InputRichMessage{ - Rtl: true, - Blocks: richTextBlocks(), + Blocks: richHeadingTableBlocks(), }, }) if err != nil { @@ -442,7 +486,32 @@ func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) { if !ok { t.Fatalf("rich-only echo missing rich message") } - assertRichTextBlocks(t, "rich-only echo", rich) + assertRichHeadingTableBlocks(t, "rich-only echo", rich) + + got, err := r.onMessagesGetMessages(WithUserID(ctx, owner.ID), []tg.InputMessageClass{&tg.InputMessageID{ID: echo.ID}}) + if err != nil { + t.Fatalf("get rich-only message: %v", err) + } + stored := singleStoredMessage(t, got) + rich, ok = stored.GetRichMessage() + if !ok { + t.Fatalf("getMessages missing rich-only message") + } + assertRichHeadingTableBlocks(t, "getMessages", rich) + + gotRich, err := r.onMessagesGetRichMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetRichMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + ID: echo.ID, + }) + if err != nil { + t.Fatalf("get rich-only message body: %v", err) + } + stored = singleStoredMessage(t, gotRich) + rich, ok = stored.GetRichMessage() + if !ok { + t.Fatalf("getRichMessage missing rich-only message") + } + assertRichHeadingTableBlocks(t, "getRichMessage", rich) } func TestEditMessageRichOnlyPrivateRoundTrip(t *testing.T) { diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index e0164268..d247c2b1 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -208,6 +208,8 @@ func messageSendErr(err error) error { return replyMessageIDInvalidErr() case errors.Is(err, domain.ErrMessageRandomIDDuplicate): return randomIDDuplicateErr() + case errors.Is(err, domain.ErrMessageEmpty): + return messageEmptyErr() default: return internalErr() } diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index 19bf3090..5912bf8b 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -180,6 +180,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee zap.Int64("random_id", p.randomID), zap.Int("message_len", utf8.RuneCountInString(p.message)), zap.Bool("has_media", p.media != nil && !p.media.IsZero()), + zap.Bool("has_rich_message", !p.richMessage.IsZero()), zap.Bool("has_reply_to", replyTo != nil), zap.Bool("clear_draft", p.clearDraft), zap.Int64("via_bot_id", p.viaBotID), diff --git a/internal/store/memory/message_send.go b/internal/store/memory/message_send.go index 336b52cc..471ed9bd 100644 --- a/internal/store/memory/message_send.go +++ b/internal/store/memory/message_send.go @@ -45,6 +45,9 @@ func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Mes } func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + if !req.HasContent() { + return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty + } fingerprint, err := store.PrivateSendFingerprint(req) if err != nil { return domain.SendPrivateTextResult{}, err diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index 6f265a25..fc365457 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -90,6 +90,35 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { } } +func TestMessageStoreSendPrivateTextContentInvariant(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + + richOnly, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 191, + Date: 1700000191, + RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks}, + }) + if err != nil { + t.Fatalf("SendPrivateText rich-only: %v", err) + } + if richOnly.SenderMessage.Body != "" || richOnly.SenderMessage.RichMessage.IsZero() { + t.Fatalf("rich-only sender message = %+v, want empty body with rich payload", richOnly.SenderMessage) + } + + _, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 192, + Date: 1700000192, + }) + if !errors.Is(err, domain.ErrMessageEmpty) { + t.Fatalf("SendPrivateText empty err = %v, want ErrMessageEmpty", err) + } +} + func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) { ctx := context.Background() messages := NewMessageStore() diff --git a/internal/store/postgres/message_rich_message_integration_test.go b/internal/store/postgres/message_rich_message_integration_test.go index 583be6b4..1ea3f3ae 100644 --- a/internal/store/postgres/message_rich_message_integration_test.go +++ b/internal/store/postgres/message_rich_message_integration_test.go @@ -3,6 +3,7 @@ package postgres import ( "bytes" "context" + "errors" "testing" "time" @@ -54,7 +55,6 @@ func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) { SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: time.Now().UnixNano(), - Message: "rich", RichMessage: rich, Date: int(time.Now().Unix()), }) @@ -121,4 +121,16 @@ func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) { if !sawEvent { t.Fatal("no new_message event for recipient") } + + // The shared store invariant still rejects a command with no text, media, or + // rich payload; accepting rich-only must not make truly empty rows possible. + _, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Date: int(time.Now().Unix()), + }) + if !errors.Is(err, domain.ErrMessageEmpty) { + t.Fatalf("send empty private message err = %v, want ErrMessageEmpty", err) + } } diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go index beb5c9c8..c0436c2d 100644 --- a/internal/store/postgres/message_send.go +++ b/internal/store/postgres/message_send.go @@ -107,8 +107,8 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP if req.RandomID == 0 { return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id") } - if req.Message == "" && req.Media.IsZero() { - return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message") + if !req.HasContent() { + return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty } if req.Date == 0 { req.Date = int(time.Now().Unix())