fix: sync rich-only private messages

This commit is contained in:
A 2026-07-17 17:00:56 +08:00
parent ae5bd43ce0
commit c18f773701
8 changed files with 132 additions and 9 deletions

View file

@ -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.

View file

@ -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) {

View file

@ -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()
}

View file

@ -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),

View file

@ -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

View file

@ -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()

View file

@ -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)
}
}

View file

@ -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())