fix: sync rich-only private messages
This commit is contained in:
parent
ae5bd43ce0
commit
c18f773701
8 changed files with 132 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue