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
|
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
|
// PrivateSendReplayRequest identifies one already-committed private send without carrying any
|
||||||
// mutable or resolver-derived message fields. The fingerprint is computed at the original
|
// mutable or resolver-derived message fields. The fingerprint is computed at the original
|
||||||
// request boundary and must be a complete SHA-256 value.
|
// 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 {
|
func richEmptyCaption() tg.PageCaption {
|
||||||
return tg.PageCaption{
|
return tg.PageCaption{
|
||||||
Text: &tg.TextEmpty{},
|
Text: &tg.TextEmpty{},
|
||||||
|
|
@ -417,9 +462,9 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
|
||||||
assertRichTextBlocks(t, "getRichMessage", rich)
|
assertRichTextBlocks(t, "getRichMessage", rich)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSendMessageRichOnlyTextBlocksRoundTrip 覆盖 TDesktop rich editor 的真实发送形态:
|
// TestSendMessageRichOnlyHeadingTableRoundTrip 覆盖 TDesktop rich editor 的真实发送形态:
|
||||||
// messages.sendMessage 带 f_rich_message,但 message:string 为空。
|
// messages.sendMessage 带 f_rich_message(标题与表格 blocks),但 message:string 为空。
|
||||||
func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) {
|
func TestSendMessageRichOnlyHeadingTableRoundTrip(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
r, owner, friend := newMediaTestRouter(t)
|
r, owner, friend := newMediaTestRouter(t)
|
||||||
|
|
||||||
|
|
@ -427,8 +472,7 @@ func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) {
|
||||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||||
RandomID: 7101,
|
RandomID: 7101,
|
||||||
RichMessage: &tg.InputRichMessage{
|
RichMessage: &tg.InputRichMessage{
|
||||||
Rtl: true,
|
Blocks: richHeadingTableBlocks(),
|
||||||
Blocks: richTextBlocks(),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -442,7 +486,32 @@ func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("rich-only echo missing rich message")
|
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) {
|
func TestEditMessageRichOnlyPrivateRoundTrip(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,8 @@ func messageSendErr(err error) error {
|
||||||
return replyMessageIDInvalidErr()
|
return replyMessageIDInvalidErr()
|
||||||
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
|
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
|
||||||
return randomIDDuplicateErr()
|
return randomIDDuplicateErr()
|
||||||
|
case errors.Is(err, domain.ErrMessageEmpty):
|
||||||
|
return messageEmptyErr()
|
||||||
default:
|
default:
|
||||||
return internalErr()
|
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.Int64("random_id", p.randomID),
|
||||||
zap.Int("message_len", utf8.RuneCountInString(p.message)),
|
zap.Int("message_len", utf8.RuneCountInString(p.message)),
|
||||||
zap.Bool("has_media", p.media != nil && !p.media.IsZero()),
|
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("has_reply_to", replyTo != nil),
|
||||||
zap.Bool("clear_draft", p.clearDraft),
|
zap.Bool("clear_draft", p.clearDraft),
|
||||||
zap.Int64("via_bot_id", p.viaBotID),
|
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) {
|
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)
|
fingerprint, err := store.PrivateSendFingerprint(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.SendPrivateTextResult{}, err
|
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) {
|
func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := NewMessageStore()
|
messages := NewMessageStore()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package postgres
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -54,7 +55,6 @@ func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) {
|
||||||
SenderUserID: sender.ID,
|
SenderUserID: sender.ID,
|
||||||
RecipientUserID: recipient.ID,
|
RecipientUserID: recipient.ID,
|
||||||
RandomID: time.Now().UnixNano(),
|
RandomID: time.Now().UnixNano(),
|
||||||
Message: "rich",
|
|
||||||
RichMessage: rich,
|
RichMessage: rich,
|
||||||
Date: int(time.Now().Unix()),
|
Date: int(time.Now().Unix()),
|
||||||
})
|
})
|
||||||
|
|
@ -121,4 +121,16 @@ func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) {
|
||||||
if !sawEvent {
|
if !sawEvent {
|
||||||
t.Fatal("no new_message event for recipient")
|
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 {
|
if req.RandomID == 0 {
|
||||||
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id")
|
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id")
|
||||||
}
|
}
|
||||||
if req.Message == "" && req.Media.IsZero() {
|
if !req.HasContent() {
|
||||||
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message")
|
return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty
|
||||||
}
|
}
|
||||||
if req.Date == 0 {
|
if req.Date == 0 {
|
||||||
req.Date = int(time.Now().Unix())
|
req.Date = int(time.Now().Unix())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue