feat: sync rich text message support

This commit is contained in:
A 2026-07-04 21:15:35 +08:00
parent 4d3bbeabd8
commit 7c9d8dda16
41 changed files with 1164 additions and 94 deletions

View file

@ -1759,7 +1759,7 @@ CREATE TABLE public.channel_messages (
reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL,
from_boosts_applied integer DEFAULT 0 NOT NULL, from_boosts_applied integer DEFAULT 0 NOT NULL,
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb))), CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb) OR (rich_message <> '{}'::jsonb))),
CONSTRAINT channel_messages_peer_type_check CHECK ((((from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])) AND ((send_as_peer_type IS NULL) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) AND (((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))))) CONSTRAINT channel_messages_peer_type_check CHECK ((((from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])) AND ((send_as_peer_type IS NULL) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) AND (((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))))
); );
@ -2472,7 +2472,7 @@ CREATE TABLE public.private_messages (
reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL,
via_bot_id bigint DEFAULT 0 NOT NULL, via_bot_id bigint DEFAULT 0 NOT NULL,
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb))) CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb) OR (rich_message <> '{}'::jsonb)))
); );
@ -2596,6 +2596,7 @@ CREATE TABLE public.scheduled_messages (
body text DEFAULT ''::text NOT NULL, body text DEFAULT ''::text NOT NULL,
entities jsonb DEFAULT '[]'::jsonb NOT NULL, entities jsonb DEFAULT '[]'::jsonb NOT NULL,
media jsonb DEFAULT '{}'::jsonb NOT NULL, media jsonb DEFAULT '{}'::jsonb NOT NULL,
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
silent boolean DEFAULT false NOT NULL, silent boolean DEFAULT false NOT NULL,
noforwards boolean DEFAULT false NOT NULL, noforwards boolean DEFAULT false NOT NULL,
reply_to_msg_id integer DEFAULT 0 NOT NULL, reply_to_msg_id integer DEFAULT 0 NOT NULL,

View file

@ -0,0 +1,20 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_content_check;
ALTER TABLE public.channel_messages
ADD CONSTRAINT channel_messages_content_check
CHECK (
body <> ''::text
OR action <> '{}'::jsonb
OR media <> '{}'::jsonb
);
ALTER TABLE public.private_messages
DROP CONSTRAINT IF EXISTS private_messages_nonempty_body;
ALTER TABLE public.private_messages
ADD CONSTRAINT private_messages_nonempty_body
CHECK (
body <> ''::text
OR media <> '{}'::jsonb
);

View file

@ -0,0 +1,22 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_content_check;
ALTER TABLE public.channel_messages
ADD CONSTRAINT channel_messages_content_check
CHECK (
body <> ''::text
OR action <> '{}'::jsonb
OR media <> '{}'::jsonb
OR rich_message <> '{}'::jsonb
);
ALTER TABLE public.private_messages
DROP CONSTRAINT IF EXISTS private_messages_nonempty_body;
ALTER TABLE public.private_messages
ADD CONSTRAINT private_messages_nonempty_body
CHECK (
body <> ''::text
OR media <> '{}'::jsonb
OR rich_message <> '{}'::jsonb
);

View file

@ -0,0 +1,2 @@
ALTER TABLE public.scheduled_messages
DROP COLUMN IF EXISTS rich_message;

View file

@ -0,0 +1,2 @@
ALTER TABLE public.scheduled_messages
ADD COLUMN IF NOT EXISTS rich_message jsonb DEFAULT '{}'::jsonb NOT NULL;

View file

@ -891,6 +891,7 @@ func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
webpage := *draft.WebPage webpage := *draft.WebPage
draft.WebPage = &webpage draft.WebPage = &webpage
} }
draft.RichMessage = cloneRichMessage(draft.RichMessage)
return draft return draft
} }
@ -927,10 +928,53 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
if d.Draft.WebPage != nil { if d.Draft.WebPage != nil {
_, _ = h.Write([]byte(d.Draft.WebPage.URL)) _, _ = h.Write([]byte(d.Draft.WebPage.URL))
} }
writeDraftRichHash(h, buf[:], d.Draft.RichMessage)
} }
return int64(h.Sum64()) return int64(h.Sum64())
} }
func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
if m == nil {
return nil
}
clone := *m
clone.Blocks = append([]byte(nil), m.Blocks...)
clone.Photos = append([]domain.Photo(nil), m.Photos...)
clone.Documents = append([]domain.Document(nil), m.Documents...)
return &clone
}
func writeDraftRichHash(h interface{ Write([]byte) (int, error) }, buf []byte, rich *domain.MessageRichMessage) {
if rich.IsZero() {
return
}
if rich.Rtl {
buf[0] = 1
} else {
buf[0] = 0
}
if rich.Part {
buf[1] = 1
} else {
buf[1] = 0
}
binary.LittleEndian.PutUint64(buf[2:10], uint64(len(rich.Blocks)))
binary.LittleEndian.PutUint64(buf[10:18], uint64(len(rich.Photos)))
binary.LittleEndian.PutUint64(buf[18:26], uint64(len(rich.Documents)))
_, _ = h.Write(buf[:26])
_, _ = h.Write(rich.Blocks)
for _, photo := range rich.Photos {
binary.LittleEndian.PutUint64(buf[:8], uint64(photo.ID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(photo.AccessHash))
_, _ = h.Write(buf[:16])
}
for _, document := range rich.Documents {
binary.LittleEndian.PutUint64(buf[:8], uint64(document.ID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(document.AccessHash))
_, _ = h.Write(buf[:16])
}
}
func mergeDialogLists(out, in domain.DialogList) domain.DialogList { func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
out.Dialogs = append(out.Dialogs, in.Dialogs...) out.Dialogs = append(out.Dialogs, in.Dialogs...)
out.Messages = append(out.Messages, in.Messages...) out.Messages = append(out.Messages, in.Messages...)

View file

@ -46,9 +46,9 @@ const tdesktopClient = "tdesktop"
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。 // 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时 // - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
// 直接读取这些 key 做本地输入限制和示例数量。 // 直接读取这些 key 做本地输入限制和示例数量。
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 19 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 const defaultAppConfigHash = 20 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。 // Service 提供客户端启动配置与国家区号目录。
// //

View file

@ -29,6 +29,9 @@ func TestAppConfigPremiumKeys(t *testing.T) {
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked { if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"]) t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
} }
if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" {
t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"])
}
wantNumbers := map[string]float64{ wantNumbers := map[string]float64{
"reactions_user_max_default": 1, "reactions_user_max_default": 1,
"reactions_user_max_premium": 3, "reactions_user_max_premium": 3,

View file

@ -7,7 +7,7 @@ import (
) )
const ( const (
appConfigHash = 14 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
countriesListHash = 1 countriesListHash = 1
timezonesListHash = 1 timezonesListHash = 1
) )
@ -40,6 +40,8 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
{Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}}, {Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}},
// DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。 // DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。
{Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}}, {Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}},
// TDesktop 富文本编辑入口:官方默认缺省 disabled,显式 enabled 才显示/允许进入编辑器。
{Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}},
// dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。 // dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}}, {Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}}, {Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},

View file

@ -39,11 +39,15 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got) t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got)
} }
values := make(map[string]float64) values := make(map[string]float64)
strings := make(map[string]string)
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil { if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
for _, entry := range object.Value { for _, entry := range object.Value {
if number, ok := entry.Value.(*tg.JSONNumber); ok { if number, ok := entry.Value.(*tg.JSONNumber); ok {
values[entry.Key] = number.Value values[entry.Key] = number.Value
} }
if str, ok := entry.Value.(*tg.JSONString); ok {
strings[entry.Key] = str.Value
}
} }
} }
want := map[string]float64{ want := map[string]float64{
@ -56,6 +60,9 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected) t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected)
} }
} }
if strings["rich_message_posting"] != "enabled" {
t.Fatalf("AppConfig[rich_message_posting] = %q, want enabled", strings["rich_message_posting"])
}
if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok { if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok {
t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash)) t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash))
} }

View file

@ -602,6 +602,8 @@ type ChannelMessage struct {
Reactions *ChannelMessageReactions Reactions *ChannelMessageReactions
Action *ChannelMessageAction Action *ChannelMessageAction
Media *MessageMedia Media *MessageMedia
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
RichMessage *MessageRichMessage
// FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。 // FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。
FromBoostsApplied int FromBoostsApplied int
TTLPeriod int TTLPeriod int
@ -1373,6 +1375,7 @@ type SendChannelMessageRequest struct {
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
GroupedID int64 GroupedID int64
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
RichMessage *MessageRichMessage
SendAs *Peer SendAs *Peer
Action *ChannelMessageAction Action *ChannelMessageAction
Date int Date int
@ -1615,6 +1618,9 @@ type EditChannelMessageRequest struct {
// SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。 // SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
// SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。
SetRichMessage bool
RichMessage *MessageRichMessage
// ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。 // ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。
ViaBotEditBotID int64 ViaBotEditBotID int64
// AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的 // AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的

View file

@ -9,7 +9,7 @@ func ChannelBannedRightsBlockMessage(req SendChannelMessageRequest, channel Chan
return false return false
} }
if req.Media.IsZero() { if req.Media.IsZero() {
if strings.TrimSpace(req.Message) == "" { if strings.TrimSpace(req.Message) == "" && req.RichMessage.IsZero() {
return false return false
} }
return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied) return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied)

View file

@ -113,6 +113,7 @@ type DialogDraft struct {
ReplyTo *MessageReply ReplyTo *MessageReply
WebPage *DialogDraftWebPage WebPage *DialogDraftWebPage
Effect int64 Effect int64
RichMessage *MessageRichMessage
} }
// Empty reports whether this draft should clear the cloud draft slot. // Empty reports whether this draft should clear the cloud draft slot.
@ -124,7 +125,8 @@ func (d DialogDraft) Empty() bool {
len(d.Entities) == 0 && len(d.Entities) == 0 &&
(d.ReplyTo == nil || replyOnlyTopic) && (d.ReplyTo == nil || replyOnlyTopic) &&
d.WebPage == nil && d.WebPage == nil &&
d.Effect == 0 d.Effect == 0 &&
d.RichMessage.IsZero()
} }
// DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs // DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs

View file

@ -418,6 +418,10 @@ type EditMessageRequest struct {
// 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。 // 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
// SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。
// TDesktop EditRichMessage 只带 f_rich_message,不带 f_message;store 不能把它误判为空编辑。
SetRichMessage bool
RichMessage *MessageRichMessage
// ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。 // ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。
ViaBotEditBotID int64 ViaBotEditBotID int64
// AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过 // AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过
@ -598,6 +602,7 @@ type ScheduledMessage struct {
Message string Message string
Entities []MessageEntity Entities []MessageEntity
Media *MessageMedia Media *MessageMedia
RichMessage *MessageRichMessage
Silent bool Silent bool
NoForwards bool NoForwards bool
ReplyTo *MessageReply ReplyTo *MessageReply
@ -626,6 +631,7 @@ type ScheduleMessageRequest struct {
Message string Message string
Entities []MessageEntity Entities []MessageEntity
Media *MessageMedia Media *MessageMedia
RichMessage *MessageRichMessage
Silent bool Silent bool
NoForwards bool NoForwards bool
ReplyTo *MessageReply ReplyTo *MessageReply
@ -639,14 +645,16 @@ type ScheduleMessageRequest struct {
// EditScheduledMessageRequest updates one pending scheduled message before it // EditScheduledMessageRequest updates one pending scheduled message before it
// enters normal history. // enters normal history.
type EditScheduledMessageRequest struct { type EditScheduledMessageRequest struct {
OwnerUserID int64 OwnerUserID int64
Peer Peer Peer Peer
ID int ID int
SetMessage bool SetMessage bool
Message string Message string
Entities []MessageEntity Entities []MessageEntity
ScheduleDate int SetRichMessage bool
Date int RichMessage *MessageRichMessage
ScheduleDate int
Date int
} }
// ScheduledMessageFilter selects scheduled messages for one owner/peer. // ScheduledMessageFilter selects scheduled messages for one owner/peer.

View file

@ -156,6 +156,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup) msg.SetReplyMarkup(markup)
} }
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
msg.SetRichMessage(*rich)
}
if replies := tgChannelMessageReplies(m.Replies); replies != nil { if replies := tgChannelMessageReplies(m.Replies); replies != nil {
msg.SetReplies(*replies) msg.SetReplies(*replies)
} }

View file

@ -162,6 +162,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
Date: d.Date, Date: d.Date,
Effect: d.Effect, Effect: d.Effect,
} }
if rich := mustTGRichMessage(d.RichMessage); rich != nil {
out.SetRichMessage(*rich)
}
return out return out
} }

View file

@ -116,9 +116,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup) msg.SetReplyMarkup(markup)
} }
// rich_message(Layer 227 富文本消息):best-effort 投影;blocks 解码失败则略过 if rich := mustTGRichMessage(m.RichMessage); rich != nil {
// (tgMessage 无 error 返回,corrupt blob 不应拖垮整条消息投影)。
if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil {
msg.SetRichMessage(*rich) msg.SetRichMessage(*rich)
} }
if m.TTLPeriod > 0 { if m.TTLPeriod > 0 {

View file

@ -2,6 +2,7 @@ package rpc
import ( import (
"context" "context"
"strconv"
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
@ -50,6 +51,81 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
return out, nil 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 快照: // domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。 // 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。 // 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
@ -62,9 +138,16 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
// Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。 // Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。
return nil, mediaInvalidErr() 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() return nil, notImplementedErr()
} }
normalizeRichBlocksForClients(in.Blocks)
blocks, err := encodeRichBlocks(in.Blocks) blocks, err := encodeRichBlocks(in.Blocks)
if err != nil { if err != nil {
return nil, err return nil, err
@ -132,3 +215,11 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
} }
return out, nil 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
}

View file

@ -43,7 +43,7 @@ func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDr
} }
update := &tg.UpdateDraftMessage{ update := &tg.UpdateDraftMessage{
Peer: peerTL, Peer: peerTL,
Draft: tgDraftMessageFromSaveDraft(req, date), Draft: tgDialogDraft(draft),
} }
if draft.TopMessageID > 0 { if draft.TopMessageID > 0 {
update.SetTopMsgID(draft.TopMessageID) update.SetTopMsgID(draft.TopMessageID)
@ -170,6 +170,13 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
if err != nil { if err != nil {
return domain.DialogDraft{}, err 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 topMessageID := 0
if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 { if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 {
topMessageID = replyTo.TopMessageID topMessageID = replyTo.TopMessageID
@ -185,6 +192,7 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
ReplyTo: replyTo, ReplyTo: replyTo,
WebPage: webpage, WebPage: webpage,
Effect: req.Effect, Effect: req.Effect,
RichMessage: richMessage,
}, nil }, nil
} }
@ -234,7 +242,8 @@ func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool {
len(req.Entities) == 0 && len(req.Entities) == 0 &&
draftInputMedia(req.Media) == nil && draftInputMedia(req.Media) == nil &&
req.Effect == 0 && 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 { func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass {

View file

@ -29,11 +29,22 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if len(entities) > maxMessageEntityCount { if len(entities) > maxMessageEntityCount {
return nil, entitiesTooLongErr() 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),与发送一致; // 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。 // 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
entities = augmentAutoEntities(message, entities) entities = augmentAutoEntities(message, entities)
} else {
entities = nil
} }
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { 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) { if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
return nil, mediaInvalidErr() 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 { if media, ok := req.GetMedia(); ok {
// 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。 // 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。
@ -65,14 +76,15 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
return nil, mediaInvalidErr() return nil, mediaInvalidErr()
} }
} }
if !hasMessage { _, hasMediaForContent := req.GetMedia()
if !hasMessage && !hasRichMessage && !hasMediaForContent {
return nil, messageEmptyErr() return nil, messageEmptyErr()
} }
if message == "" { if message == "" && richMessage == nil {
// 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only // 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only
// 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会 // 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会
// 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。 // 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。
if _, hasMedia := req.GetMedia(); !hasMedia { if !hasMediaForContent {
return nil, messageEmptyErr() return nil, messageEmptyErr()
} }
} }
@ -107,6 +119,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
Message: message, Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities), Entities: domainMessageEntitiesForViewer(userID, entities),
MentionUserIDs: mentionUserIDs, MentionUserIDs: mentionUserIDs,
SetRichMessage: hasRichMessage,
RichMessage: richMessage,
EditDate: int(r.clock.Now().Unix()), EditDate: int(r.clock.Now().Unix()),
}) })
if err != nil { if err != nil {
@ -141,6 +155,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
OriginSessionID: sessionID, OriginSessionID: sessionID,
SetReplyMarkup: setReplyMarkup, SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup, ReplyMarkup: replyMarkup,
SetRichMessage: hasRichMessage,
RichMessage: richMessage,
}) })
if err != nil { if err != nil {
return nil, messageEditErr(err) return nil, messageEditErr(err)

View file

@ -4,19 +4,149 @@ import (
"context" "context"
"testing" "testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg" "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/domain"
"telesrv/internal/store/memory"
) )
// richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。 // richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。
func richTextBlocks() []tg.PageBlockClass { func richTextBlocks() []tg.PageBlockClass {
return richTextBlocksWith("Rich Title", "First paragraph.")
}
func richTextBlocksWith(title, paragraph string) []tg.PageBlockClass {
return []tg.PageBlockClass{ return []tg.PageBlockClass{
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "Rich Title"}}, &tg.PageBlockTitle{Text: &tg.TextPlain{Text: title}},
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "First paragraph."}}, &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 的两个块(标题+段落)。 // assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。
func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) { func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
t.Helper() 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 的 // TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的
// blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。 // blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。
func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
@ -95,6 +417,154 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
assertRichTextBlocks(t, "getRichMessage", rich) 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 // TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer
// 拉取应返回 messageEmpty(不跨会话泄漏)。 // 拉取应返回 messageEmpty(不跨会话泄漏)。
func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) { func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
@ -182,3 +652,104 @@ func singleStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message
} }
return msg 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
}

View file

@ -193,6 +193,7 @@ func (r *Router) scheduleOutgoing(ctx context.Context, userID int64, peer domain
Message: p.message, Message: p.message,
Entities: domainMessageEntitiesForViewer(userID, p.entities), Entities: domainMessageEntitiesForViewer(userID, p.entities),
Media: p.media, Media: p.media,
RichMessage: p.richMessage,
Silent: p.silent, Silent: p.silent,
NoForwards: p.noforwards, NoForwards: p.noforwards,
ReplyTo: replyTo, ReplyTo: replyTo,
@ -227,12 +228,13 @@ func (r *Router) sendClaimedScheduledMessages(ctx context.Context, userID int64,
sentIDs := make([]int, 0, len(claimed)) sentIDs := make([]int, 0, len(claimed))
for _, scheduled := range claimed { for _, scheduled := range claimed {
updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{ updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{
randomID: scheduled.RandomID, randomID: scheduled.RandomID,
message: scheduled.Message, message: scheduled.Message,
entities: tgInputMessageEntities(scheduled.Entities), entities: tgInputMessageEntities(scheduled.Entities),
media: scheduled.Media, media: scheduled.Media,
silent: scheduled.Silent, richMessage: scheduled.RichMessage,
noforwards: scheduled.NoForwards, silent: scheduled.Silent,
noforwards: scheduled.NoForwards,
}) })
if err != nil { if err != nil {
if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok { if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok {
@ -340,6 +342,7 @@ func scheduledMessageAsDomainMessage(msg domain.ScheduledMessage, viewerUserID i
ReplyTo: msg.ReplyTo, ReplyTo: msg.ReplyTo,
Forward: msg.Forward, Forward: msg.Forward,
Media: msg.Media, Media: msg.Media,
RichMessage: msg.RichMessage,
} }
} }
@ -427,7 +430,7 @@ func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, from
return updates, nil 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 { if r.deps.Messages == nil {
return nil, messageIDInvalidErr() return nil, messageIDInvalidErr()
} }
@ -440,14 +443,16 @@ func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer do
return nil, scheduleDateInvalidErr() return nil, scheduleDateInvalidErr()
} }
msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{ msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{
OwnerUserID: userID, OwnerUserID: userID,
Peer: peer, Peer: peer,
ID: id, ID: id,
SetMessage: setMessage, SetMessage: setMessage,
Message: message, Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities), Entities: domainMessageEntitiesForViewer(userID, entities),
ScheduleDate: scheduleDate, SetRichMessage: setRichMessage,
Date: now, RichMessage: richMessage,
ScheduleDate: scheduleDate,
Date: now,
}) })
if err != nil { if err != nil {
return nil, messageEditErr(err) return nil, messageEditErr(err)

View file

@ -16,10 +16,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
defer func() { defer func() {
r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr) r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr)
}() }()
if req.Message == "" {
sendErr = messageEmptyErr()
return nil, messageEmptyErr()
}
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
sendErr = messageTooLongErr() sendErr = messageTooLongErr()
return nil, sendErr return nil, sendErr
@ -101,6 +97,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr return nil, sendErr
} }
} }
if req.Message == "" && richMessage == nil {
sendErr = messageEmptyErr()
return nil, sendErr
}
// 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端 // 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端
// 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。 // 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。
if richMessage == nil { if richMessage == nil {
@ -123,6 +123,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
replyToInput: req.ReplyTo, replyToInput: req.ReplyTo,
sendAsInput: req.SendAs, sendAsInput: req.SendAs,
clearDraft: req.ClearDraft, clearDraft: req.ClearDraft,
richMessage: richMessage,
}, req.ScheduleDate, req.ScheduleRepeatPeriod) }, req.ScheduleDate, req.ScheduleRepeatPeriod)
if err != nil { if err != nil {
sendErr = err sendErr = err

View file

@ -39,7 +39,6 @@ type outgoingSend struct {
replyMarkup *domain.MessageReplyMarkup replyMarkup *domain.MessageReplyMarkup
viaBotID int64 viaBotID int64
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。 // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
// Phase 1 仅接入私聊;频道侧留 Phase 2。
richMessage *domain.MessageRichMessage richMessage *domain.MessageRichMessage
// groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染 // groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染
// 成一个相册组);单条发送恒 0。 // 成一个相册组);单条发送恒 0。
@ -92,6 +91,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
ViaBotID: p.viaBotID, ViaBotID: p.viaBotID,
GroupedID: p.groupedID, GroupedID: p.groupedID,
ReplyMarkup: p.replyMarkup, ReplyMarkup: p.replyMarkup,
RichMessage: p.richMessage,
SendAs: sendAs, SendAs: sendAs,
Date: int(r.clock.Now().Unix()), Date: int(r.clock.Now().Unix()),
}) })

View file

@ -9,8 +9,7 @@ import (
) )
func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
// 空文本只在媒体替换(live location 续报/停止)时合法。 if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 {
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
} }
s.mu.Lock() s.mu.Lock()
@ -26,6 +25,17 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
} }
prevMsg := s.messages[req.ChannelID][idx] prevMsg := s.messages[req.ChannelID][idx]
msg := prevMsg msg := prevMsg
finalMedia := msg.Media
if req.Media != nil {
finalMedia = req.Media
}
finalRich := msg.RichMessage
if req.SetRichMessage {
finalRich = req.RichMessage
}
if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
// WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、 // WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、
// 不碰 body/entities/edit_date,事件为 channel_web_page。 // 不碰 body/entities/edit_date,事件为 channel_web_page。
if req.WebPageResolve { if req.WebPageResolve {
@ -67,7 +77,8 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit { if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit {
return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired
} }
if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) {
return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified
} }
pts := s.nextChannelPtsLocked(req.ChannelID) pts := s.nextChannelPtsLocked(req.ChannelID)
@ -80,6 +91,9 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
if req.SetReplyMarkup { if req.SetReplyMarkup {
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
} }
if req.SetRichMessage {
msg.RichMessage = cloneRichMessage(req.RichMessage)
}
msg.EditDate = req.EditDate msg.EditDate = req.EditDate
msg.Pts = pts msg.Pts = pts
s.messages[req.ChannelID][idx] = msg s.messages[req.ChannelID][idx] = msg

View file

@ -30,6 +30,7 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
in.ReplyTo = cloneMessageReply(in.ReplyTo) in.ReplyTo = cloneMessageReply(in.ReplyTo)
in.Forward = cloneMessageForward(in.Forward) in.Forward = cloneMessageForward(in.Forward)
in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup) in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup)
in.RichMessage = cloneRichMessage(in.RichMessage)
in.Discussion = cloneChannelDiscussionRef(in.Discussion) in.Discussion = cloneChannelDiscussionRef(in.Discussion)
in.Replies = cloneChannelMessageReplies(in.Replies) in.Replies = cloneChannelMessageReplies(in.Replies)
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions)

View file

@ -11,7 +11,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
if req.UserID == 0 || req.ChannelID == 0 { if req.UserID == 0 || req.ChannelID == 0 {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() { if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
s.mu.Lock() s.mu.Lock()
@ -83,6 +83,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards,
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
RichMessage: cloneRichMessage(req.RichMessage),
Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID},
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
@ -131,6 +132,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: req.Media,
RichMessage: cloneRichMessage(req.RichMessage),
ReplyTo: replyTo, ReplyTo: replyTo,
Forward: cloneMessageForward(req.Forward), Forward: cloneMessageForward(req.Forward),
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,

View file

@ -840,6 +840,7 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
webpage := *draft.WebPage webpage := *draft.WebPage
draft.WebPage = &webpage draft.WebPage = &webpage
} }
draft.RichMessage = cloneRichMessage(draft.RichMessage)
return draft return draft
} }

View file

@ -41,10 +41,19 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
return res, domain.ErrMessageNotModified return res, domain.ErrMessageNotModified
} }
} }
if req.Message == "" && req.Media == nil && target.Media.IsZero() { finalMedia := target.Media
if req.Media != nil {
finalMedia = req.Media
}
finalRich := target.RichMessage
if req.SetRichMessage {
finalRich = req.RichMessage
}
if req.Message == "" && finalMedia.IsZero() && finalRich.IsZero() {
return res, domain.ErrMessageEmpty return res, domain.ErrMessageEmpty
} }
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(target.RichMessage, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) {
return res, domain.ErrMessageNotModified return res, domain.ErrMessageNotModified
} }
messageSenderID := target.From.ID messageSenderID := target.From.ID
@ -73,6 +82,9 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
// 替换 markup(nil/空 = 清空键盘);双盒一致。 // 替换 markup(nil/空 = 清空键盘);双盒一致。
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
} }
if req.SetRichMessage {
msg.RichMessage = cloneRichMessage(req.RichMessage)
}
msg.EditDate = req.EditDate msg.EditDate = req.EditDate
msg.HideEdited = req.HideEdited msg.HideEdited = req.HideEdited
msg.Pts = s.nextPtsLocked(userID) msg.Pts = s.nextPtsLocked(userID)

View file

@ -3,6 +3,7 @@ package memory
import ( import (
"encoding/binary" "encoding/binary"
"hash/fnv" "hash/fnv"
"reflect"
"sort" "sort"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -118,6 +119,13 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
return &clone return &clone
} }
func richMessagesEqual(a, b *domain.MessageRichMessage) bool {
if a.IsZero() && b.IsZero() {
return true
}
return reflect.DeepEqual(a, b)
}
func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply {
if reply == nil { if reply == nil {
return nil return nil

View file

@ -9,6 +9,12 @@ import (
"telesrv/internal/domain" "telesrv/internal/domain"
) )
var validRichMessageBlocks = []byte{
0x15, 0xc4, 0xb5, 0x1c, // vector
0x01, 0x00, 0x00, 0x00, // count
0x8a, 0x7e, 0x56, 0x13, // pageBlockUnsupported
}
func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
ctx := context.Background() ctx := context.Background()
dialogs := NewDialogStore() dialogs := NewDialogStore()
@ -84,6 +90,41 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
} }
} }
func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 201,
Date: 1700000200,
RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks},
})
if err != nil {
t.Fatalf("SendPrivateText rich-only: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
ID: sent.SenderMessage.ID,
Message: "",
HideEdited: true,
EditDate: 1700000210,
})
if err != nil {
t.Fatalf("EditMessage rich-only hide-edit: %v", err)
}
if len(edited.Edited) != 2 {
t.Fatalf("edited boxes = %d, want 2", len(edited.Edited))
}
for _, item := range edited.Edited {
if item.Message.RichMessage.IsZero() {
t.Fatalf("edited rich-only box for user %d lost rich message: %+v", item.UserID, item.Message)
}
}
}
func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
messages := NewMessageStore() messages := NewMessageStore()

View file

@ -12,8 +12,7 @@ import (
) )
func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
// 空文本只在媒体替换(live location 续报/停止)时合法。 if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 {
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
} }
beginner, ok := s.db.(txBeginner) beginner, ok := s.db.(txBeginner)
@ -31,6 +30,10 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh
if err != nil { if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit reply markup: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit reply markup: %w", err)
} }
richMessageJSON, err := encodeRichMessage(req.RichMessage)
if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit rich message: %w", err)
}
tx, err := beginner.Begin(ctx) tx, err := beginner.Begin(ctx)
if err != nil { if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err)
@ -52,6 +55,17 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh
if msg.Deleted || msg.Action != nil { if msg.Deleted || msg.Action != nil {
return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid
} }
finalMedia := msg.Media
if req.Media != nil {
finalMedia = req.Media
}
finalRich := msg.RichMessage
if req.SetRichMessage {
finalRich = req.RichMessage
}
if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.WebPageResolve { if req.WebPageResolve {
// 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts + // 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts +
// channel_web_page 事件。幂等守卫:仅当前 media 仍是匹配 id 的 pending 占位才换。 // channel_web_page 事件。幂等守卫:仅当前 media 仍是匹配 id 的 pending 占位才换。
@ -110,7 +124,8 @@ WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON, pts); err
if !canWriteEdit { if !canWriteEdit {
return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired
} }
if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) {
return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified
} }
ptsCount := 1 ptsCount := 1
@ -130,9 +145,10 @@ SET body = $4,
edit_date = $6, edit_date = $6,
pts = $7, pts = $7,
reply_markup = CASE WHEN $9 THEN $10::jsonb ELSE reply_markup END, reply_markup = CASE WHEN $9 THEN $10::jsonb ELSE reply_markup END,
rich_message = CASE WHEN $11 THEN $12::jsonb ELSE rich_message END,
updated_at = now() updated_at = now()
WHERE channel_id = $1 AND id = $2 AND NOT deleted AND (sender_user_id = $3 OR $8)`, WHERE channel_id = $1 AND id = $2 AND NOT deleted AND (sender_user_id = $3 OR $8)`,
req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON)); err != nil { req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON), req.SetRichMessage, string(richMessageJSON)); err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err)
} }
if req.Media != nil { if req.Media != nil {
@ -160,6 +176,12 @@ WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON); err != ni
return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit reply markup: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit reply markup: %w", err)
} }
} }
if req.SetRichMessage {
msg.RichMessage, err = decodeRichMessage(string(richMessageJSON))
if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit rich message: %w", err)
}
}
msg.EditDate = req.EditDate msg.EditDate = req.EditDate
msg.Pts = editPts msg.Pts = editPts
event := domain.ChannelUpdateEvent{ event := domain.ChannelUpdateEvent{

View file

@ -30,6 +30,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
var entities, reply, forward, action string var entities, reply, forward, action string
var mediaJSON string var mediaJSON string
var replyMarkupJSON string var replyMarkupJSON string
var richMessageJSON string
var savedPeerType string var savedPeerType string
var savedPeerID int64 var savedPeerID int64
if err := row.Scan( if err := row.Scan(
@ -37,7 +38,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
&replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID,
); err != nil { ); err != nil {
return domain.ChannelMessage{}, err return domain.ChannelMessage{}, err
} }
@ -65,6 +66,10 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
if err != nil { if err != nil {
return domain.ChannelMessage{}, err return domain.ChannelMessage{}, err
} }
msg.RichMessage, err = decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ChannelMessage{}, err
}
return msg, nil return msg, nil
} }
@ -82,6 +87,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
var count int var count int
var mediaJSON string var mediaJSON string
var replyMarkupJSON string var replyMarkupJSON string
var richMessageJSON string
var savedPeerType string var savedPeerType string
var savedPeerID int64 var savedPeerID int64
if err := row.Scan( if err := row.Scan(
@ -89,7 +95,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
&replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count,
); err != nil { ); err != nil {
return domain.ChannelMessage{}, 0, err return domain.ChannelMessage{}, 0, err
} }
@ -117,6 +123,10 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
if err != nil { if err != nil {
return domain.ChannelMessage{}, 0, err return domain.ChannelMessage{}, 0, err
} }
msg.RichMessage, err = decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ChannelMessage{}, 0, err
}
return msg, count, nil return msg, count, nil
} }

View file

@ -11,7 +11,7 @@ import (
) )
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if req.Date == 0 { if req.Date == 0 {
@ -123,6 +123,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: req.Media,
RichMessage: req.RichMessage,
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
ReplyMarkup: req.ReplyMarkup, ReplyMarkup: req.ReplyMarkup,
@ -180,6 +181,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: req.Media,
RichMessage: req.RichMessage,
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
ReplyMarkup: req.ReplyMarkup, ReplyMarkup: req.ReplyMarkup,
@ -422,6 +424,10 @@ func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMe
if err != nil { if err != nil {
return err return err
} }
richMessage, err := encodeRichMessage(msg.RichMessage)
if err != nil {
return err
}
var sendAsType sql.NullString var sendAsType sql.NullString
var sendAsID sql.NullInt64 var sendAsID sql.NullInt64
if msg.SendAs != nil && msg.SendAs.ID != 0 { if msg.SendAs != nil && msg.SendAs.ID != 0 {
@ -450,12 +456,12 @@ INSERT INTO channel_messages (
channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id,
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards,
body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36)`, ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37)`,
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID,
sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID,
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil { forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil {
return fmt.Errorf("insert channel message: %w", err) return fmt.Errorf("insert channel message: %w", err)
} }
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。

View file

@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body,
entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text, fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text,
reply_markup::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id`
const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id,
title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,

View file

@ -2,6 +2,7 @@ package postgres
import ( import (
"encoding/json" "encoding/json"
"reflect"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -93,6 +94,13 @@ func decodeRichMessage(s string) (*domain.MessageRichMessage, error) {
return &m, nil return &m, nil
} }
func richMessagesEqual(a, b *domain.MessageRichMessage) bool {
if a.IsZero() && b.IsZero() {
return true
}
return reflect.DeepEqual(a, b)
}
func decodePhotoSizes(s string) ([]domain.PhotoSize, error) { func decodePhotoSizes(s string) ([]domain.PhotoSize, error) {
if s == "" || s == "[]" || s == "null" { if s == "" || s == "[]" || s == "null" {
return nil, nil return nil, nil

View file

@ -54,17 +54,26 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe
PeerType: string(req.Peer.Type), PeerType: string(req.Peer.Type),
PeerID: req.Peer.ID, PeerID: req.Peer.ID,
}) })
// 空文本只在目标消息携带媒体(或本次写入媒体)时合法(清空 caption);
// 纯文本消息清空会留下既无 body 也无 media 的空壳。
if err == nil && req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") {
return res, domain.ErrMessageEmpty
}
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return res, domain.ErrMessageIDInvalid return res, domain.ErrMessageIDInvalid
} }
return res, fmt.Errorf("get message for edit: %w", err) return res, fmt.Errorf("get message for edit: %w", err)
} }
targetRich, err := decodeRichMessage(target.RichMessageJson)
if err != nil {
return res, fmt.Errorf("decode target rich message: %w", err)
}
// 空文本只在目标消息携带媒体/rich(或本次写入媒体/rich)时合法(清空 caption)。
if req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") {
if req.SetRichMessage {
if req.RichMessage.IsZero() {
return res, domain.ErrMessageEmpty
}
} else if targetRich.IsZero() {
return res, domain.ErrMessageEmpty
}
}
oldEntities, err := decodeMessageEntities(target.EntitiesJson) oldEntities, err := decodeMessageEntities(target.EntitiesJson)
if err != nil { if err != nil {
return res, fmt.Errorf("decode target entities: %w", err) return res, fmt.Errorf("decode target entities: %w", err)
@ -74,13 +83,18 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe
if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validTodoParticipantEdit(req, target, oldEntities) { if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validTodoParticipantEdit(req, target, oldEntities) {
return res, domain.ErrMessageAuthorRequired return res, domain.ErrMessageAuthorRequired
} }
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && sameMessageEntities(oldEntities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(targetRich, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && target.Body == req.Message && target.HideEdited == req.HideEdited && sameMessageEntities(oldEntities, req.Entities) {
return res, domain.ErrMessageNotModified return res, domain.ErrMessageNotModified
} }
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
if err != nil { if err != nil {
return res, fmt.Errorf("encode edit reply markup: %w", err) return res, fmt.Errorf("encode edit reply markup: %w", err)
} }
richMessageJSON, err := encodeRichMessage(req.RichMessage)
if err != nil {
return res, fmt.Errorf("encode edit rich message: %w", err)
}
messageSenderID := target.MessageSenderID messageSenderID := target.MessageSenderID
boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(req.OwnerUserID, req.Peer.ID), OwnerUserIds: privateMessageOwnerIDs(req.OwnerUserID, req.Peer.ID),
@ -173,6 +187,8 @@ WHERE owner_user_id = $1 AND box_id = $2`, box.OwnerUserID, box.BoxID, int32(pts
HideEdited: req.HideEdited, HideEdited: req.HideEdited,
SetReplyMarkup: req.SetReplyMarkup, SetReplyMarkup: req.SetReplyMarkup,
ReplyMarkupJson: replyMarkupJSON, ReplyMarkupJson: replyMarkupJSON,
SetRichMessage: req.SetRichMessage,
RichMessageJson: richMessageJSON,
}); err != nil { }); err != nil {
return res, fmt.Errorf("update private message edit: %w", err) return res, fmt.Errorf("update private message edit: %w", err)
} }
@ -210,6 +226,8 @@ WHERE message_sender_id = $1 AND private_message_id = $2`, messageSenderID, targ
Pts: int32(pts), Pts: int32(pts),
SetReplyMarkup: req.SetReplyMarkup, SetReplyMarkup: req.SetReplyMarkup,
ReplyMarkupJson: replyMarkupJSON, ReplyMarkupJson: replyMarkupJSON,
SetRichMessage: req.SetRichMessage,
RichMessageJson: richMessageJSON,
}) })
if err != nil { if err != nil {
return res, fmt.Errorf("update message box edit: %w", err) return res, fmt.Errorf("update message box edit: %w", err)

View file

@ -1036,6 +1036,10 @@ SET body = sqlc.arg(body)::text,
reply_markup = CASE reply_markup = CASE
WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN sqlc.arg(set_rich_message)::boolean THEN sqlc.arg(rich_message_json)::jsonb
ELSE rich_message
END END
WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint
AND id = sqlc.arg(private_message_id)::bigint; AND id = sqlc.arg(private_message_id)::bigint;
@ -1050,6 +1054,10 @@ SET body = sqlc.arg(body)::text,
reply_markup = CASE reply_markup = CASE
WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN sqlc.arg(set_rich_message)::boolean THEN sqlc.arg(rich_message_json)::jsonb
ELSE rich_message
END END
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int AND box_id = sqlc.arg(box_id)::int

View file

@ -27,7 +27,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel { if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel {
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer") return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer")
} }
if req.Message == "" && req.Media.IsZero() { if req.Message == "" && req.Media.IsZero() && req.RichMessage.IsZero() {
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message") return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message")
} }
if req.Date == 0 { if req.Date == 0 {
@ -41,6 +41,10 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if err != nil { if err != nil {
return domain.ScheduledMessage{}, err return domain.ScheduledMessage{}, err
} }
richMessage, err := encodeRichMessage(req.RichMessage)
if err != nil {
return domain.ScheduledMessage{}, err
}
meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward) meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward)
if err != nil { if err != nil {
return domain.ScheduledMessage{}, err return domain.ScheduledMessage{}, err
@ -87,7 +91,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO scheduled_messages ( INSERT INTO scheduled_messages (
owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date,
body, entities, media, silent, noforwards, body, entities, media, rich_message, silent, noforwards,
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
quote_text, quote_entities, quote_offset, quote_text, quote_entities, quote_offset,
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
@ -95,14 +99,14 @@ INSERT INTO scheduled_messages (
schedule_date, schedule_repeat_period, state, created_at, updated_at schedule_date, schedule_repeat_period, state, created_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6,
$7, $8::jsonb, $9::jsonb, $10, $11, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12,
$12, $13, $14, $15, $13, $14, $15, $16,
$16, $17::jsonb, $18, $17, $18::jsonb, $19,
$19, $20, $21, $22, $20, $21, $22, $23,
$23, $24, $24, $25,
$25, $26, 'pending', $27, $27 $26, $27, 'pending', $28, $28
)`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date, )`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date,
req.Message, entities, media, req.Silent, req.NoForwards, req.Message, entities, media, richMessage, req.Silent, req.NoForwards,
meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID, meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID,
meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset, meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset,
meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate, meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate,
@ -167,29 +171,38 @@ func (s *MessageStore) EditScheduledMessage(ctx context.Context, req domain.Edit
} }
message := current.Message message := current.Message
entities := append([]domain.MessageEntity(nil), current.Entities...) entities := append([]domain.MessageEntity(nil), current.Entities...)
richMessage := current.RichMessage
if req.SetMessage { if req.SetMessage {
if req.Message == "" && current.Media.IsZero() {
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
}
message = req.Message message = req.Message
entities = append([]domain.MessageEntity(nil), req.Entities...) entities = append([]domain.MessageEntity(nil), req.Entities...)
} }
if req.SetRichMessage {
richMessage = req.RichMessage
}
if message == "" && current.Media.IsZero() && richMessage.IsZero() {
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
}
encodedEntities, err := encodeMessageEntities(entities) encodedEntities, err := encodeMessageEntities(entities)
if err != nil { if err != nil {
return domain.ScheduledMessage{}, err return domain.ScheduledMessage{}, err
} }
encodedRichMessage, err := encodeRichMessage(richMessage)
if err != nil {
return domain.ScheduledMessage{}, err
}
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
UPDATE scheduled_messages UPDATE scheduled_messages
SET body = $5, SET body = $5,
entities = $6::jsonb, entities = $6::jsonb,
schedule_date = $7, rich_message = $7::jsonb,
updated_at = $8 schedule_date = $8,
updated_at = $9
WHERE owner_user_id = $1 WHERE owner_user_id = $1
AND peer_type = $2 AND peer_type = $2
AND peer_id = $3 AND peer_id = $3
AND scheduled_id = $4 AND scheduled_id = $4
AND state = 'pending' AND state = 'pending'
RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, req.ScheduleDate, req.Date) RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, encodedRichMessage, req.ScheduleDate, req.Date)
msg, err := scanScheduledMessage(row) msg, err := scanScheduledMessage(row)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -472,7 +485,7 @@ LIMIT $4`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, limit)
func scheduledMessageSelectColumns() string { func scheduledMessageSelectColumns() string {
return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date,
body, entities::text, media::text, silent, noforwards, body, entities::text, media::text, rich_message::text, silent, noforwards,
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
quote_text, quote_entities::text, quote_offset, quote_text, quote_entities::text, quote_offset,
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
@ -486,7 +499,7 @@ func scheduledMessageSelectColumnsFor(alias string) string {
} }
prefix := alias + "." prefix := alias + "."
return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date, return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date,
` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `silent, ` + prefix + `noforwards, ` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `rich_message::text, ` + prefix + `silent, ` + prefix + `noforwards,
` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id, ` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id,
` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset, ` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset,
` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date, ` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date,
@ -516,6 +529,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
peerType string peerType string
entitiesJSON string entitiesJSON string
mediaJSON string mediaJSON string
richMessageJSON string
replyToMsgID int32 replyToMsgID int32
replyToPeerType string replyToPeerType string
replyToPeerID int64 replyToPeerID int64
@ -533,7 +547,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
) )
if err := scanner.Scan( if err := scanner.Scan(
&msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt, &msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt,
&msg.Message, &entitiesJSON, &mediaJSON, &msg.Silent, &msg.NoForwards, &msg.Message, &entitiesJSON, &mediaJSON, &richMessageJSON, &msg.Silent, &msg.NoForwards,
&replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID, &replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID,
&quoteText, &quoteEntitiesJSON, &quoteOffset, &quoteText, &quoteEntitiesJSON, &quoteOffset,
&fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate, &fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate,
@ -554,6 +568,11 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err) return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err)
} }
msg.Media = media msg.Media = media
richMessage, err := decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled rich message: %w", err)
}
msg.RichMessage = richMessage
// scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时 // scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时
// 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。 // 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。
_, _, reply, forward, err := messageMetadataFromFields( _, _, reply, forward, err := messageMetadataFromFields(

View file

@ -107,4 +107,72 @@ func TestScheduledMessageEditPreservesContentWhenMessageUnset(t *testing.T) {
if !errors.Is(err, domain.ErrMessageEmpty) { if !errors.Is(err, domain.ErrMessageEmpty) {
t.Fatalf("empty text scheduled edit err = %v, want ErrMessageEmpty", err) t.Fatalf("empty text scheduled edit err = %v, want ErrMessageEmpty", err)
} }
richInitial := &domain.MessageRichMessage{Rtl: true, Blocks: []byte{1, 2, 3, 4}}
richOnly, err := messages.CreateScheduledMessage(ctx, domain.ScheduleMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
RandomID: 7003,
RichMessage: richInitial,
ScheduleDate: 1700021600,
Date: 1700000500,
})
if err != nil {
t.Fatalf("create rich scheduled message: %v", err)
}
if !richMessagesEqual(richOnly.RichMessage, richInitial) || richOnly.Message != "" || !richOnly.Media.IsZero() {
t.Fatalf("rich scheduled create = %+v, want rich-only content", richOnly)
}
gotRich, err := messages.GetScheduledMessages(ctx, domain.ScheduledMessageFilter{
OwnerUserID: owner.ID,
Peer: peer,
IDs: []int{richOnly.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("get rich scheduled message: %v", err)
}
if len(gotRich.Messages) != 1 || !richMessagesEqual(gotRich.Messages[0].RichMessage, richInitial) {
t.Fatalf("get rich scheduled = %+v, want original rich", gotRich.Messages)
}
richDateOnly, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
ID: richOnly.ID,
ScheduleDate: 1700025200,
Date: 1700000600,
})
if err != nil {
t.Fatalf("date-only edit rich scheduled message: %v", err)
}
if richDateOnly.ScheduleDate != 1700025200 || !richMessagesEqual(richDateOnly.RichMessage, richInitial) {
t.Fatalf("date-only rich scheduled edit = %+v, want original rich and new date", richDateOnly)
}
richEdited := &domain.MessageRichMessage{Part: true, Blocks: []byte{5, 6, 7}}
editedRich, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
ID: richOnly.ID,
SetRichMessage: true,
RichMessage: richEdited,
ScheduleDate: 1700028800,
Date: 1700000700,
})
if err != nil {
t.Fatalf("edit rich scheduled message: %v", err)
}
if !richMessagesEqual(editedRich.RichMessage, richEdited) || editedRich.ScheduleDate != 1700028800 {
t.Fatalf("edited rich scheduled = %+v, want replacement rich", editedRich)
}
_, err = messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
ID: richOnly.ID,
SetRichMessage: true,
ScheduleDate: 1700032400,
Date: 1700000800,
})
if !errors.Is(err, domain.ErrMessageEmpty) {
t.Fatalf("clear only rich scheduled edit err = %v, want ErrMessageEmpty", err)
}
} }

View file

@ -3554,9 +3554,13 @@ SET body = $1::text,
reply_markup = CASE reply_markup = CASE
WHEN $6::boolean THEN $7::jsonb WHEN $6::boolean THEN $7::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN $8::boolean THEN $9::jsonb
ELSE rich_message
END END
WHERE owner_user_id = $8::bigint WHERE owner_user_id = $10::bigint
AND box_id = $9::int AND box_id = $11::int
AND NOT deleted AND NOT deleted
RETURNING RETURNING
box_id, box_id,
@ -3613,6 +3617,8 @@ type UpdateMessageBoxEditParams struct {
Pts int32 Pts int32
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkupJson []byte ReplyMarkupJson []byte
SetRichMessage bool
RichMessageJson []byte
OwnerUserID int64 OwnerUserID int64
BoxID int32 BoxID int32
} }
@ -3673,6 +3679,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox
arg.Pts, arg.Pts,
arg.SetReplyMarkup, arg.SetReplyMarkup,
arg.ReplyMarkupJson, arg.ReplyMarkupJson,
arg.SetRichMessage,
arg.RichMessageJson,
arg.OwnerUserID, arg.OwnerUserID,
arg.BoxID, arg.BoxID,
) )
@ -3735,9 +3743,13 @@ SET body = $1::text,
reply_markup = CASE reply_markup = CASE
WHEN $5::boolean THEN $6::jsonb WHEN $5::boolean THEN $6::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN $7::boolean THEN $8::jsonb
ELSE rich_message
END END
WHERE sender_user_id = $7::bigint WHERE sender_user_id = $9::bigint
AND id = $8::bigint AND id = $10::bigint
` `
type UpdatePrivateMessageEditParams struct { type UpdatePrivateMessageEditParams struct {
@ -3747,6 +3759,8 @@ type UpdatePrivateMessageEditParams struct {
HideEdited bool HideEdited bool
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkupJson []byte ReplyMarkupJson []byte
SetRichMessage bool
RichMessageJson []byte
SenderUserID int64 SenderUserID int64
PrivateMessageID int64 PrivateMessageID int64
} }
@ -3759,6 +3773,8 @@ func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivat
arg.HideEdited, arg.HideEdited,
arg.SetReplyMarkup, arg.SetReplyMarkup,
arg.ReplyMarkupJson, arg.ReplyMarkupJson,
arg.SetRichMessage,
arg.RichMessageJson,
arg.SenderUserID, arg.SenderUserID,
arg.PrivateMessageID, arg.PrivateMessageID,
) )