feat: sync rich text message support
This commit is contained in:
parent
4d3bbeabd8
commit
7c9d8dda16
41 changed files with 1164 additions and 94 deletions
|
|
@ -156,6 +156,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
|
||||
msg.SetRichMessage(*rich)
|
||||
}
|
||||
if replies := tgChannelMessageReplies(m.Replies); replies != nil {
|
||||
msg.SetReplies(*replies)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
|
|||
Date: d.Date,
|
||||
Effect: d.Effect,
|
||||
}
|
||||
if rich := mustTGRichMessage(d.RichMessage); rich != nil {
|
||||
out.SetRichMessage(*rich)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,9 +116,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
|
|||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
// rich_message(Layer 227 富文本消息):best-effort 投影;blocks 解码失败则略过
|
||||
// (tgMessage 无 error 返回,corrupt blob 不应拖垮整条消息投影)。
|
||||
if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil {
|
||||
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
|
||||
msg.SetRichMessage(*rich)
|
||||
}
|
||||
if m.TTLPeriod > 0 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/tg"
|
||||
|
|
@ -50,6 +51,81 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeRichBlocksForClients(blocks []tg.PageBlockClass) {
|
||||
for _, block := range blocks {
|
||||
normalizeRichBlockForClients(block)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRichBlockForClients(block tg.PageBlockClass) {
|
||||
switch b := block.(type) {
|
||||
case *tg.PageBlockList:
|
||||
for _, item := range b.Items {
|
||||
if item, ok := item.(*tg.PageListItemBlocks); ok {
|
||||
normalizeRichBlocksForClients(item.Blocks)
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockCover:
|
||||
normalizeRichBlockForClients(b.Cover)
|
||||
case *tg.PageBlockEmbedPost:
|
||||
normalizeRichBlocksForClients(b.Blocks)
|
||||
case *tg.PageBlockCollage:
|
||||
normalizeRichBlocksForClients(b.Items)
|
||||
case *tg.PageBlockSlideshow:
|
||||
normalizeRichBlocksForClients(b.Items)
|
||||
case *tg.PageBlockOrderedList:
|
||||
normalizeOrderedListForClients(b)
|
||||
case *tg.PageBlockDetails:
|
||||
normalizeRichBlocksForClients(b.Blocks)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
normalizeRichBlocksForClients(b.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
|
||||
if list == nil {
|
||||
return
|
||||
}
|
||||
reversed := list.Reversed || list.Flags.Has(2)
|
||||
current := 1
|
||||
if list.Flags.Has(0) || list.Start != 0 {
|
||||
current = list.Start
|
||||
} else if reversed {
|
||||
current = len(list.Items)
|
||||
}
|
||||
step := 1
|
||||
if reversed {
|
||||
step = -1
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
value := current
|
||||
switch i := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
if v, ok := i.GetValue(); ok || i.Value != 0 {
|
||||
value = v
|
||||
if !ok {
|
||||
value = i.Value
|
||||
}
|
||||
}
|
||||
if num, ok := i.GetNum(); !ok || num == "" {
|
||||
i.SetNum(strconv.Itoa(value))
|
||||
}
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
if v, ok := i.GetValue(); ok || i.Value != 0 {
|
||||
value = v
|
||||
if !ok {
|
||||
value = i.Value
|
||||
}
|
||||
}
|
||||
if num, ok := i.GetNum(); !ok || num == "" {
|
||||
i.SetNum(strconv.Itoa(value))
|
||||
}
|
||||
normalizeRichBlocksForClients(i.Blocks)
|
||||
}
|
||||
current = value + step
|
||||
}
|
||||
}
|
||||
|
||||
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
|
||||
// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。
|
||||
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
|
||||
|
|
@ -62,9 +138,16 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
|
|||
// Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
if r.deps.Files == nil {
|
||||
if len(in.Blocks) == 0 {
|
||||
if len(in.Photos) == 0 && len(in.Documents) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
normalizeRichBlocksForClients(in.Blocks)
|
||||
blocks, err := encodeRichBlocks(in.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -132,3 +215,11 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustTGRichMessage(m *domain.MessageRichMessage) *tg.RichMessage {
|
||||
out, err := tgRichMessage(m)
|
||||
if err != nil {
|
||||
panic("invalid stored rich_message: " + err.Error())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDr
|
|||
}
|
||||
update := &tg.UpdateDraftMessage{
|
||||
Peer: peerTL,
|
||||
Draft: tgDraftMessageFromSaveDraft(req, date),
|
||||
Draft: tgDialogDraft(draft),
|
||||
}
|
||||
if draft.TopMessageID > 0 {
|
||||
update.SetTopMsgID(draft.TopMessageID)
|
||||
|
|
@ -170,6 +170,13 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
|
|||
if err != nil {
|
||||
return domain.DialogDraft{}, err
|
||||
}
|
||||
var richMessage *domain.MessageRichMessage
|
||||
if req.RichMessage != nil {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
|
||||
if err != nil {
|
||||
return domain.DialogDraft{}, err
|
||||
}
|
||||
}
|
||||
topMessageID := 0
|
||||
if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 {
|
||||
topMessageID = replyTo.TopMessageID
|
||||
|
|
@ -185,6 +192,7 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
|
|||
ReplyTo: replyTo,
|
||||
WebPage: webpage,
|
||||
Effect: req.Effect,
|
||||
RichMessage: richMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +242,8 @@ func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool {
|
|||
len(req.Entities) == 0 &&
|
||||
draftInputMedia(req.Media) == nil &&
|
||||
req.Effect == 0 &&
|
||||
req.SuggestedPost.Zero()
|
||||
req.SuggestedPost.Zero() &&
|
||||
req.RichMessage == nil
|
||||
}
|
||||
|
||||
func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,22 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if len(entities) > maxMessageEntityCount {
|
||||
return nil, entitiesTooLongErr()
|
||||
}
|
||||
} else {
|
||||
entities = nil
|
||||
}
|
||||
var richMessage *domain.MessageRichMessage
|
||||
hasRichMessage := req.RichMessage != nil
|
||||
if hasRichMessage {
|
||||
var richErr error
|
||||
richMessage, richErr = r.domainRichMessageFromInput(ctx, req.RichMessage)
|
||||
if richErr != nil {
|
||||
return nil, richErr
|
||||
}
|
||||
}
|
||||
if hasMessage && richMessage == nil {
|
||||
// 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
|
||||
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
|
||||
entities = augmentAutoEntities(message, entities)
|
||||
} else {
|
||||
entities = nil
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -50,7 +61,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, scheduleDate)
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
|
||||
}
|
||||
if media, ok := req.GetMedia(); ok {
|
||||
// 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。
|
||||
|
|
@ -65,14 +76,15 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
return nil, mediaInvalidErr()
|
||||
}
|
||||
}
|
||||
if !hasMessage {
|
||||
_, hasMediaForContent := req.GetMedia()
|
||||
if !hasMessage && !hasRichMessage && !hasMediaForContent {
|
||||
return nil, messageEmptyErr()
|
||||
}
|
||||
if message == "" {
|
||||
if message == "" && richMessage == nil {
|
||||
// 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only
|
||||
// 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会
|
||||
// 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。
|
||||
if _, hasMedia := req.GetMedia(); !hasMedia {
|
||||
if !hasMediaForContent {
|
||||
return nil, messageEmptyErr()
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +119,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SetRichMessage: hasRichMessage,
|
||||
RichMessage: richMessage,
|
||||
EditDate: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -141,6 +155,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
OriginSessionID: sessionID,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: hasRichMessage,
|
||||
RichMessage: richMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, messageEditErr(err)
|
||||
|
|
|
|||
|
|
@ -4,19 +4,149 @@ import (
|
|||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appdialogs "telesrv/internal/app/dialogs"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。
|
||||
func richTextBlocks() []tg.PageBlockClass {
|
||||
return richTextBlocksWith("Rich Title", "First paragraph.")
|
||||
}
|
||||
|
||||
func richTextBlocksWith(title, paragraph string) []tg.PageBlockClass {
|
||||
return []tg.PageBlockClass{
|
||||
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "Rich Title"}},
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "First paragraph."}},
|
||||
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: title}},
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: paragraph}},
|
||||
}
|
||||
}
|
||||
|
||||
func richEmptyCaption() tg.PageCaption {
|
||||
return tg.PageCaption{
|
||||
Text: &tg.TextEmpty{},
|
||||
Credit: &tg.TextEmpty{},
|
||||
}
|
||||
}
|
||||
|
||||
func richOrderedListWithoutNums() []tg.PageBlockClass {
|
||||
return []tg.PageBlockClass{
|
||||
&tg.PageBlockOrderedList{
|
||||
Items: []tg.PageListOrderedItemClass{
|
||||
&tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}},
|
||||
&tg.PageListOrderedItemBlocks{
|
||||
Blocks: []tg.PageBlockClass{
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "two"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func richNestedOrderedListBlock() tg.PageBlockClass {
|
||||
return richOrderedListWithoutNums()[0]
|
||||
}
|
||||
|
||||
func assertOrderedListNums(t *testing.T, label string, blocks []tg.PageBlockClass, want ...string) {
|
||||
t.Helper()
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("%s: blocks = %d, want 1", label, len(blocks))
|
||||
}
|
||||
list, ok := blocks[0].(*tg.PageBlockOrderedList)
|
||||
if !ok {
|
||||
t.Fatalf("%s: block[0] = %T, want *tg.PageBlockOrderedList", label, blocks[0])
|
||||
}
|
||||
if len(list.Items) != len(want) {
|
||||
t.Fatalf("%s: items = %d, want %d", label, len(list.Items), len(want))
|
||||
}
|
||||
for idx, item := range list.Items {
|
||||
var (
|
||||
num string
|
||||
ok bool
|
||||
)
|
||||
switch i := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
num, ok = i.GetNum()
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
num, ok = i.GetNum()
|
||||
default:
|
||||
t.Fatalf("%s: item[%d] = %T, want ordered text/blocks", label, idx, item)
|
||||
}
|
||||
if !ok || num != want[idx] {
|
||||
t.Fatalf("%s: item[%d].num = %q, ok=%v, want %q", label, idx, num, ok, want[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectOrderedListNums(blocks []tg.PageBlockClass) []string {
|
||||
var nums []string
|
||||
var walk func(tg.PageBlockClass)
|
||||
walk = func(block tg.PageBlockClass) {
|
||||
switch b := block.(type) {
|
||||
case *tg.PageBlockList:
|
||||
for _, item := range b.Items {
|
||||
if item, ok := item.(*tg.PageListItemBlocks); ok {
|
||||
for _, child := range item.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockCover:
|
||||
walk(b.Cover)
|
||||
case *tg.PageBlockEmbedPost:
|
||||
for _, child := range b.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockCollage:
|
||||
for _, child := range b.Items {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockSlideshow:
|
||||
for _, child := range b.Items {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockDetails:
|
||||
for _, child := range b.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
for _, child := range b.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockOrderedList:
|
||||
for _, item := range b.Items {
|
||||
switch i := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
if num, ok := i.GetNum(); ok {
|
||||
nums = append(nums, num)
|
||||
} else {
|
||||
nums = append(nums, "")
|
||||
}
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
if num, ok := i.GetNum(); ok {
|
||||
nums = append(nums, num)
|
||||
} else {
|
||||
nums = append(nums, "")
|
||||
}
|
||||
for _, child := range i.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, block := range blocks {
|
||||
walk(block)
|
||||
}
|
||||
return nums
|
||||
}
|
||||
|
||||
// assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。
|
||||
func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
|
||||
t.Helper()
|
||||
|
|
@ -42,6 +172,198 @@ func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
|
|||
}
|
||||
}
|
||||
|
||||
func assertRichTitle(t *testing.T, label string, rich tg.RichMessage, want string) {
|
||||
t.Helper()
|
||||
if len(rich.Blocks) == 0 {
|
||||
t.Fatalf("%s: missing rich blocks", label)
|
||||
}
|
||||
title, ok := rich.Blocks[0].(*tg.PageBlockTitle)
|
||||
if !ok {
|
||||
t.Fatalf("%s: block[0] = %T, want *tg.PageBlockTitle", label, rich.Blocks[0])
|
||||
}
|
||||
if tp, ok := title.Text.(*tg.TextPlain); !ok || tp.Text != want {
|
||||
t.Fatalf("%s: title text = %+v, want plain %q", label, title.Text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
|
||||
Blocks: richOrderedListWithoutNums(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("domain rich message: %v", err)
|
||||
}
|
||||
got, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("tg rich message: %v", err)
|
||||
}
|
||||
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
|
||||
}
|
||||
|
||||
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{})
|
||||
if err != nil {
|
||||
t.Fatalf("empty input rich message: %v", err)
|
||||
}
|
||||
if rich != nil {
|
||||
t.Fatalf("empty input rich message = %+v, want nil", rich)
|
||||
}
|
||||
if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
|
||||
Photos: []tg.InputPhotoClass{&tg.InputPhoto{ID: 1, AccessHash: 2}},
|
||||
}); err == nil {
|
||||
t.Fatalf("orphan rich photos without blocks accepted")
|
||||
}
|
||||
if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
|
||||
Documents: []tg.InputDocumentClass{&tg.InputDocument{ID: 1, AccessHash: 2}},
|
||||
}); err == nil {
|
||||
t.Fatalf("orphan rich documents without blocks accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageNormalizesNestedOrderedListContainers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
caption := richEmptyCaption()
|
||||
blocks := []tg.PageBlockClass{
|
||||
&tg.PageBlockList{Items: []tg.PageListItemClass{
|
||||
&tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}},
|
||||
}},
|
||||
&tg.PageBlockCover{Cover: richNestedOrderedListBlock()},
|
||||
&tg.PageBlockEmbedPost{
|
||||
URL: "https://example.test/post",
|
||||
Author: "author",
|
||||
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
|
||||
Caption: caption,
|
||||
WebpageID: 1,
|
||||
},
|
||||
&tg.PageBlockCollage{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption},
|
||||
&tg.PageBlockSlideshow{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption},
|
||||
&tg.PageBlockDetails{
|
||||
Title: &tg.TextPlain{Text: "details"},
|
||||
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
|
||||
},
|
||||
&tg.PageBlockBlockquoteBlocks{
|
||||
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
|
||||
Caption: &tg.TextEmpty{},
|
||||
},
|
||||
}
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks})
|
||||
if err != nil {
|
||||
t.Fatalf("domain rich message: %v", err)
|
||||
}
|
||||
got, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("tg rich message: %v", err)
|
||||
}
|
||||
nums := collectOrderedListNums(got.Blocks)
|
||||
want := []string{"1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2"}
|
||||
if len(nums) != len(want) {
|
||||
t.Fatalf("ordered nums = %v, want %v", nums, want)
|
||||
}
|
||||
for i := range want {
|
||||
if nums[i] != want[i] {
|
||||
t.Fatalf("ordered nums = %v, want %v", nums, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageBlockFormatsEncodeDecode(t *testing.T) {
|
||||
caption := richEmptyCaption()
|
||||
blocks := []tg.PageBlockClass{
|
||||
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "title"}},
|
||||
&tg.PageBlockSubtitle{Text: &tg.TextPlain{Text: "subtitle"}},
|
||||
&tg.PageBlockAuthorDate{Author: &tg.TextPlain{Text: "author"}, PublishedDate: 1},
|
||||
&tg.PageBlockHeader{Text: &tg.TextPlain{Text: "header"}},
|
||||
&tg.PageBlockSubheader{Text: &tg.TextPlain{Text: "subheader"}},
|
||||
&tg.PageBlockParagraph{Text: &tg.TextConcat{Texts: []tg.RichTextClass{
|
||||
&tg.TextPlain{Text: "plain"},
|
||||
&tg.TextBold{Text: &tg.TextPlain{Text: "bold"}},
|
||||
&tg.TextItalic{Text: &tg.TextPlain{Text: "italic"}},
|
||||
&tg.TextUnderline{Text: &tg.TextPlain{Text: "underline"}},
|
||||
&tg.TextStrike{Text: &tg.TextPlain{Text: "strike"}},
|
||||
&tg.TextFixed{Text: &tg.TextPlain{Text: "fixed"}},
|
||||
&tg.TextSpoiler{Text: &tg.TextPlain{Text: "spoiler"}},
|
||||
&tg.TextURL{Text: &tg.TextPlain{Text: "url"}, URL: "https://example.test"},
|
||||
&tg.TextEmail{Text: &tg.TextPlain{Text: "email"}, Email: "a@example.test"},
|
||||
&tg.TextPhone{Text: &tg.TextPlain{Text: "phone"}, Phone: "+10000000000"},
|
||||
&tg.TextMath{Source: "x"},
|
||||
}}},
|
||||
&tg.PageBlockPreformatted{Text: &tg.TextPlain{Text: "pre"}, Language: "go"},
|
||||
&tg.PageBlockFooter{Text: &tg.TextPlain{Text: "footer"}},
|
||||
&tg.PageBlockDivider{},
|
||||
&tg.PageBlockAnchor{Name: "anchor"},
|
||||
&tg.PageBlockList{Items: []tg.PageListItemClass{
|
||||
&tg.PageListItemText{Text: &tg.TextPlain{Text: "item"}},
|
||||
&tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "nested"}},
|
||||
}},
|
||||
}},
|
||||
&tg.PageBlockBlockquote{Text: &tg.TextPlain{Text: "quote"}, Caption: &tg.TextEmpty{}},
|
||||
&tg.PageBlockPullquote{Text: &tg.TextPlain{Text: "pull"}, Caption: &tg.TextEmpty{}},
|
||||
&tg.PageBlockPhoto{PhotoID: 1, Caption: caption},
|
||||
&tg.PageBlockVideo{VideoID: 2, Caption: caption},
|
||||
&tg.PageBlockCover{Cover: &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "cover"}}},
|
||||
&tg.PageBlockEmbedPost{
|
||||
URL: "https://example.test/post",
|
||||
Author: "author",
|
||||
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "post"}}},
|
||||
Caption: caption,
|
||||
WebpageID: 3,
|
||||
},
|
||||
&tg.PageBlockCollage{Items: []tg.PageBlockClass{&tg.PageBlockPhoto{PhotoID: 4, Caption: caption}}, Caption: caption},
|
||||
&tg.PageBlockSlideshow{Items: []tg.PageBlockClass{&tg.PageBlockVideo{VideoID: 5, Caption: caption}}, Caption: caption},
|
||||
&tg.PageBlockAudio{AudioID: 6, Caption: caption},
|
||||
&tg.PageBlockKicker{Text: &tg.TextPlain{Text: "kicker"}},
|
||||
&tg.PageBlockTable{Title: &tg.TextPlain{Text: "table"}},
|
||||
&tg.PageBlockOrderedList{Items: []tg.PageListOrderedItemClass{
|
||||
&tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}},
|
||||
}},
|
||||
&tg.PageBlockDetails{Title: &tg.TextPlain{Text: "details"}, Blocks: []tg.PageBlockClass{
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "inside"}},
|
||||
}},
|
||||
&tg.PageBlockRelatedArticles{Title: &tg.TextPlain{Text: "related"}, Articles: []tg.PageRelatedArticle{
|
||||
{URL: "https://example.test/a", WebpageID: 7},
|
||||
}},
|
||||
&tg.PageBlockMap{Geo: &tg.GeoPointEmpty{}, Zoom: 13, W: 64, H: 64, Caption: caption},
|
||||
&tg.PageBlockHeading1{Text: &tg.TextPlain{Text: "h1"}},
|
||||
&tg.PageBlockHeading2{Text: &tg.TextPlain{Text: "h2"}},
|
||||
&tg.PageBlockHeading3{Text: &tg.TextPlain{Text: "h3"}},
|
||||
&tg.PageBlockHeading4{Text: &tg.TextPlain{Text: "h4"}},
|
||||
&tg.PageBlockHeading5{Text: &tg.TextPlain{Text: "h5"}},
|
||||
&tg.PageBlockHeading6{Text: &tg.TextPlain{Text: "h6"}},
|
||||
&tg.PageBlockMath{Source: "x^2"},
|
||||
&tg.PageBlockThinking{Text: &tg.TextPlain{Text: "thinking"}},
|
||||
&tg.PageBlockBlockquoteBlocks{
|
||||
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "blocks"}}},
|
||||
Caption: &tg.TextEmpty{},
|
||||
},
|
||||
&tg.PageBlockUnsupported{},
|
||||
}
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks})
|
||||
if err != nil {
|
||||
t.Fatalf("domain rich message: %v", err)
|
||||
}
|
||||
got, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("tg rich message: %v", err)
|
||||
}
|
||||
if len(got.Blocks) != len(blocks) {
|
||||
t.Fatalf("blocks = %d, want %d", len(got.Blocks), len(blocks))
|
||||
}
|
||||
nums := collectOrderedListNums(got.Blocks)
|
||||
if len(nums) != 1 || nums[0] != "1" {
|
||||
t.Fatalf("ordered nums = %v, want [1]", nums)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的
|
||||
// blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。
|
||||
func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
|
||||
|
|
@ -95,6 +417,154 @@ 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) {
|
||||
ctx := context.Background()
|
||||
r, owner, friend := newMediaTestRouter(t)
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
RandomID: 7101,
|
||||
RichMessage: &tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
Blocks: richTextBlocks(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send rich-only message: %v", err)
|
||||
}
|
||||
echo := newMessageFromUpdates(t, updates)
|
||||
if echo.Message != "" {
|
||||
t.Fatalf("rich-only echo message = %q, want empty fallback text", echo.Message)
|
||||
}
|
||||
rich, ok := echo.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("rich-only echo missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "rich-only echo", rich)
|
||||
}
|
||||
|
||||
func TestEditMessageRichOnlyPrivateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, friend := newMediaTestRouter(t)
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
RandomID: 7102,
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send rich-only message: %v", err)
|
||||
}
|
||||
msg := newMessageFromUpdates(t, updates)
|
||||
|
||||
editReq := &tg.MessagesEditMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
ID: msg.ID,
|
||||
}
|
||||
editReq.SetRichMessage(&tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
Blocks: richTextBlocksWith("Edited Title", "Edited paragraph."),
|
||||
})
|
||||
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
|
||||
if err != nil {
|
||||
t.Fatalf("edit rich-only private message: %v", err)
|
||||
}
|
||||
editedMsg := editMessageFromUpdates(t, edited)
|
||||
rich, ok := editedMsg.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("edited private message missing rich message")
|
||||
}
|
||||
assertRichTitle(t, "edited private", rich, "Edited Title")
|
||||
}
|
||||
|
||||
func TestChannelRichMessageSendEditHistoryRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, channel := newRichChannelTestRouter(t)
|
||||
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: peer,
|
||||
RandomID: 7201,
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel rich-only message: %v", err)
|
||||
}
|
||||
echo := newMessageFromUpdates(t, updates)
|
||||
rich, ok := echo.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("channel echo missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "channel echo", rich)
|
||||
|
||||
historyList, err := r.deps.Channels.GetHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channel.ID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("channel get history: %v", err)
|
||||
}
|
||||
history := r.tgChannelHistoryMessages(WithUserID(ctx, owner.ID), owner.ID, historyList)
|
||||
stored := singleChannelStoredMessage(t, history)
|
||||
rich, ok = stored.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("channel history missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "channel history", rich)
|
||||
|
||||
editReq := &tg.MessagesEditMessageRequest{Peer: peer, ID: echo.ID}
|
||||
editReq.SetRichMessage(&tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
Blocks: richTextBlocksWith("Edited Channel", "Edited channel paragraph."),
|
||||
})
|
||||
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
|
||||
if err != nil {
|
||||
t.Fatalf("edit channel rich-only message: %v", err)
|
||||
}
|
||||
editedMsg := editChannelMessageFromUpdates(t, edited)
|
||||
rich, ok = editedMsg.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("edited channel message missing rich message")
|
||||
}
|
||||
assertRichTitle(t, "edited channel", rich, "Edited Channel")
|
||||
}
|
||||
|
||||
func TestSaveDraftRichMessageRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, friend := newRichDraftTestRouter(t)
|
||||
|
||||
ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("save rich draft = %v, %v", ok, err)
|
||||
}
|
||||
got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("get all drafts: %v", err)
|
||||
}
|
||||
updates := got.(*tg.Updates)
|
||||
if len(updates.Updates) != 1 {
|
||||
t.Fatalf("draft updates = %+v, want one", updates.Updates)
|
||||
}
|
||||
update, ok := updates.Updates[0].(*tg.UpdateDraftMessage)
|
||||
if !ok {
|
||||
t.Fatalf("draft update = %T", updates.Updates[0])
|
||||
}
|
||||
draft, ok := update.Draft.(*tg.DraftMessage)
|
||||
if !ok {
|
||||
t.Fatalf("draft = %T, want *tg.DraftMessage", update.Draft)
|
||||
}
|
||||
rich, ok := draft.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("draft missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "draft", rich)
|
||||
}
|
||||
|
||||
// TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer
|
||||
// 拉取应返回 messageEmpty(不跨会话泄漏)。
|
||||
func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
|
||||
|
|
@ -182,3 +652,104 @@ func singleStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message
|
|||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func singleChannelStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message {
|
||||
t.Helper()
|
||||
box, ok := res.(*tg.MessagesChannelMessages)
|
||||
if !ok {
|
||||
t.Fatalf("channel messages = %T %+v, want messages.channelMessages", res, res)
|
||||
}
|
||||
var got *tg.Message
|
||||
for _, item := range box.Messages {
|
||||
msg, ok := item.(*tg.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("channel messages = %+v, want one regular message", box.Messages)
|
||||
}
|
||||
got = msg
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("channel messages = %+v, want one regular message", box.Messages)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func editMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
|
||||
t.Helper()
|
||||
upd, ok := updates.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
||||
}
|
||||
for _, u := range upd.Updates {
|
||||
if edit, ok := u.(*tg.UpdateEditMessage); ok {
|
||||
msg, ok := edit.Message.(*tg.Message)
|
||||
if !ok {
|
||||
t.Fatalf("edit message = %T, want *tg.Message", edit.Message)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatal("no updateEditMessage found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func editChannelMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
|
||||
t.Helper()
|
||||
upd, ok := updates.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
||||
}
|
||||
for _, u := range upd.Updates {
|
||||
if edit, ok := u.(*tg.UpdateEditChannelMessage); ok {
|
||||
msg, ok := edit.Message.(*tg.Message)
|
||||
if !ok {
|
||||
t.Fatalf("edit channel message = %T, want *tg.Message", edit.Message)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatal("no updateEditChannelMessage found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRichChannelTestRouter(t *testing.T) (*Router, domain.User, domain.Channel) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550009101", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelSvc := appchannels.NewService(channelStore)
|
||||
created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Rich Channel",
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create rich channel: %v", err)
|
||||
}
|
||||
dialogStore := memory.NewDialogStore()
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelSvc,
|
||||
Dialogs: appdialogs.NewService(dialogStore, channelStore),
|
||||
Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, owner, created.Channel
|
||||
}
|
||||
|
||||
func newRichDraftTestRouter(t *testing.T) (*Router, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550009201", FirstName: "Owner"})
|
||||
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550009202", FirstName: "Friend"})
|
||||
dialogStore := memory.NewDialogStore()
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Dialogs: appdialogs.NewService(dialogStore, memory.NewChannelStore()),
|
||||
Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, owner, friend
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ func (r *Router) scheduleOutgoing(ctx context.Context, userID int64, peer domain
|
|||
Message: p.message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, p.entities),
|
||||
Media: p.media,
|
||||
RichMessage: p.richMessage,
|
||||
Silent: p.silent,
|
||||
NoForwards: p.noforwards,
|
||||
ReplyTo: replyTo,
|
||||
|
|
@ -227,12 +228,13 @@ func (r *Router) sendClaimedScheduledMessages(ctx context.Context, userID int64,
|
|||
sentIDs := make([]int, 0, len(claimed))
|
||||
for _, scheduled := range claimed {
|
||||
updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{
|
||||
randomID: scheduled.RandomID,
|
||||
message: scheduled.Message,
|
||||
entities: tgInputMessageEntities(scheduled.Entities),
|
||||
media: scheduled.Media,
|
||||
silent: scheduled.Silent,
|
||||
noforwards: scheduled.NoForwards,
|
||||
randomID: scheduled.RandomID,
|
||||
message: scheduled.Message,
|
||||
entities: tgInputMessageEntities(scheduled.Entities),
|
||||
media: scheduled.Media,
|
||||
richMessage: scheduled.RichMessage,
|
||||
silent: scheduled.Silent,
|
||||
noforwards: scheduled.NoForwards,
|
||||
})
|
||||
if err != nil {
|
||||
if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok {
|
||||
|
|
@ -340,6 +342,7 @@ func scheduledMessageAsDomainMessage(msg domain.ScheduledMessage, viewerUserID i
|
|||
ReplyTo: msg.ReplyTo,
|
||||
Forward: msg.Forward,
|
||||
Media: msg.Media,
|
||||
RichMessage: msg.RichMessage,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -427,7 +430,7 @@ func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, from
|
|||
return updates, nil
|
||||
}
|
||||
|
||||
func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, scheduleDate int) (tg.UpdatesClass, error) {
|
||||
func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, richMessage *domain.MessageRichMessage, setRichMessage bool, scheduleDate int) (tg.UpdatesClass, error) {
|
||||
if r.deps.Messages == nil {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
|
|
@ -440,14 +443,16 @@ func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer do
|
|||
return nil, scheduleDateInvalidErr()
|
||||
}
|
||||
msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{
|
||||
OwnerUserID: userID,
|
||||
Peer: peer,
|
||||
ID: id,
|
||||
SetMessage: setMessage,
|
||||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
ScheduleDate: scheduleDate,
|
||||
Date: now,
|
||||
OwnerUserID: userID,
|
||||
Peer: peer,
|
||||
ID: id,
|
||||
SetMessage: setMessage,
|
||||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
SetRichMessage: setRichMessage,
|
||||
RichMessage: richMessage,
|
||||
ScheduleDate: scheduleDate,
|
||||
Date: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, messageEditErr(err)
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
defer func() {
|
||||
r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr)
|
||||
}()
|
||||
if req.Message == "" {
|
||||
sendErr = messageEmptyErr()
|
||||
return nil, messageEmptyErr()
|
||||
}
|
||||
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
|
||||
sendErr = messageTooLongErr()
|
||||
return nil, sendErr
|
||||
|
|
@ -101,6 +97,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
if req.Message == "" && richMessage == nil {
|
||||
sendErr = messageEmptyErr()
|
||||
return nil, sendErr
|
||||
}
|
||||
// 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端
|
||||
// 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。
|
||||
if richMessage == nil {
|
||||
|
|
@ -123,6 +123,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
replyToInput: req.ReplyTo,
|
||||
sendAsInput: req.SendAs,
|
||||
clearDraft: req.ClearDraft,
|
||||
richMessage: richMessage,
|
||||
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
||||
if err != nil {
|
||||
sendErr = err
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ type outgoingSend struct {
|
|||
replyMarkup *domain.MessageReplyMarkup
|
||||
viaBotID int64
|
||||
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
|
||||
// Phase 1 仅接入私聊;频道侧留 Phase 2。
|
||||
richMessage *domain.MessageRichMessage
|
||||
// groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染
|
||||
// 成一个相册组);单条发送恒 0。
|
||||
|
|
@ -92,6 +91,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
|
|||
ViaBotID: p.viaBotID,
|
||||
GroupedID: p.groupedID,
|
||||
ReplyMarkup: p.replyMarkup,
|
||||
RichMessage: p.richMessage,
|
||||
SendAs: sendAs,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue