feat: sync rich text message support
This commit is contained in:
parent
4d3bbeabd8
commit
7c9d8dda16
41 changed files with 1164 additions and 94 deletions
|
|
@ -1759,7 +1759,7 @@ CREATE TABLE public.channel_messages (
|
|||
reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
from_boosts_applied integer DEFAULT 0 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])))))
|
||||
);
|
||||
|
||||
|
|
@ -2472,7 +2472,7 @@ CREATE TABLE public.private_messages (
|
|||
reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
via_bot_id bigint DEFAULT 0 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,
|
||||
entities jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
media jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
silent boolean DEFAULT false NOT NULL,
|
||||
noforwards boolean DEFAULT false NOT NULL,
|
||||
reply_to_msg_id integer DEFAULT 0 NOT NULL,
|
||||
|
|
|
|||
20
deploy/migrations/0050_rich_message_content_checks.down.sql
Normal file
20
deploy/migrations/0050_rich_message_content_checks.down.sql
Normal 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
|
||||
);
|
||||
22
deploy/migrations/0050_rich_message_content_checks.up.sql
Normal file
22
deploy/migrations/0050_rich_message_content_checks.up.sql
Normal 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
|
||||
);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE public.scheduled_messages
|
||||
DROP COLUMN IF EXISTS rich_message;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE public.scheduled_messages
|
||||
ADD COLUMN IF NOT EXISTS rich_message jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||
|
|
@ -891,6 +891,7 @@ func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
|
|||
webpage := *draft.WebPage
|
||||
draft.WebPage = &webpage
|
||||
}
|
||||
draft.RichMessage = cloneRichMessage(draft.RichMessage)
|
||||
return draft
|
||||
}
|
||||
|
||||
|
|
@ -927,10 +928,53 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
|
|||
if d.Draft.WebPage != nil {
|
||||
_, _ = h.Write([]byte(d.Draft.WebPage.URL))
|
||||
}
|
||||
writeDraftRichHash(h, buf[:], d.Draft.RichMessage)
|
||||
}
|
||||
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 {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.Messages = append(out.Messages, in.Messages...)
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ const tdesktopClient = "tdesktop"
|
|||
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
||||
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
||||
// 直接读取这些 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 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || 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{
|
||||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
appConfigHash = 14 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
countriesListHash = 1
|
||||
timezonesListHash = 1
|
||||
)
|
||||
|
|
@ -40,6 +40,8 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
|
|||
{Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}},
|
||||
// DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。
|
||||
{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 入口。
|
||||
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
|
||||
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},
|
||||
|
|
|
|||
|
|
@ -39,11 +39,15 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
|
|||
t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got)
|
||||
}
|
||||
values := make(map[string]float64)
|
||||
strings := make(map[string]string)
|
||||
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
|
||||
for _, entry := range object.Value {
|
||||
if number, ok := entry.Value.(*tg.JSONNumber); ok {
|
||||
values[entry.Key] = number.Value
|
||||
}
|
||||
if str, ok := entry.Value.(*tg.JSONString); ok {
|
||||
strings[entry.Key] = str.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
want := map[string]float64{
|
||||
|
|
@ -56,6 +60,9 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
|
|||
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 {
|
||||
t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -602,6 +602,8 @@ type ChannelMessage struct {
|
|||
Reactions *ChannelMessageReactions
|
||||
Action *ChannelMessageAction
|
||||
Media *MessageMedia
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
// FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。
|
||||
FromBoostsApplied int
|
||||
TTLPeriod int
|
||||
|
|
@ -1373,6 +1375,7 @@ type SendChannelMessageRequest struct {
|
|||
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
|
||||
GroupedID int64
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
RichMessage *MessageRichMessage
|
||||
SendAs *Peer
|
||||
Action *ChannelMessageAction
|
||||
Date int
|
||||
|
|
@ -1615,6 +1618,9 @@ type EditChannelMessageRequest struct {
|
|||
// SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。
|
||||
SetRichMessage bool
|
||||
RichMessage *MessageRichMessage
|
||||
// ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。
|
||||
ViaBotEditBotID int64
|
||||
// AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ func ChannelBannedRightsBlockMessage(req SendChannelMessageRequest, channel Chan
|
|||
return false
|
||||
}
|
||||
if req.Media.IsZero() {
|
||||
if strings.TrimSpace(req.Message) == "" {
|
||||
if strings.TrimSpace(req.Message) == "" && req.RichMessage.IsZero() {
|
||||
return false
|
||||
}
|
||||
return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ type DialogDraft struct {
|
|||
ReplyTo *MessageReply
|
||||
WebPage *DialogDraftWebPage
|
||||
Effect int64
|
||||
RichMessage *MessageRichMessage
|
||||
}
|
||||
|
||||
// Empty reports whether this draft should clear the cloud draft slot.
|
||||
|
|
@ -124,7 +125,8 @@ func (d DialogDraft) Empty() bool {
|
|||
len(d.Entities) == 0 &&
|
||||
(d.ReplyTo == nil || replyOnlyTopic) &&
|
||||
d.WebPage == nil &&
|
||||
d.Effect == 0
|
||||
d.Effect == 0 &&
|
||||
d.RichMessage.IsZero()
|
||||
}
|
||||
|
||||
// DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs
|
||||
|
|
|
|||
|
|
@ -418,6 +418,10 @@ type EditMessageRequest struct {
|
|||
// 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。
|
||||
// TDesktop EditRichMessage 只带 f_rich_message,不带 f_message;store 不能把它误判为空编辑。
|
||||
SetRichMessage bool
|
||||
RichMessage *MessageRichMessage
|
||||
// ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。
|
||||
ViaBotEditBotID int64
|
||||
// AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过
|
||||
|
|
@ -598,6 +602,7 @@ type ScheduledMessage struct {
|
|||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
RichMessage *MessageRichMessage
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
|
|
@ -626,6 +631,7 @@ type ScheduleMessageRequest struct {
|
|||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
RichMessage *MessageRichMessage
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
|
|
@ -645,6 +651,8 @@ type EditScheduledMessageRequest struct {
|
|||
SetMessage bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
SetRichMessage bool
|
||||
RichMessage *MessageRichMessage
|
||||
ScheduleDate int
|
||||
Date int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
|
||||
msg.SetRichMessage(*rich)
|
||||
}
|
||||
if replies := tgChannelMessageReplies(m.Replies); replies != nil {
|
||||
msg.SetReplies(*replies)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
|
|||
Date: d.Date,
|
||||
Effect: d.Effect,
|
||||
}
|
||||
if rich := mustTGRichMessage(d.RichMessage); rich != nil {
|
||||
out.SetRichMessage(*rich)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,9 +116,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
|
|||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
// rich_message(Layer 227 富文本消息):best-effort 投影;blocks 解码失败则略过
|
||||
// (tgMessage 无 error 返回,corrupt blob 不应拖垮整条消息投影)。
|
||||
if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil {
|
||||
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
|
||||
msg.SetRichMessage(*rich)
|
||||
}
|
||||
if m.TTLPeriod > 0 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/tg"
|
||||
|
|
@ -50,6 +51,81 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeRichBlocksForClients(blocks []tg.PageBlockClass) {
|
||||
for _, block := range blocks {
|
||||
normalizeRichBlockForClients(block)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRichBlockForClients(block tg.PageBlockClass) {
|
||||
switch b := block.(type) {
|
||||
case *tg.PageBlockList:
|
||||
for _, item := range b.Items {
|
||||
if item, ok := item.(*tg.PageListItemBlocks); ok {
|
||||
normalizeRichBlocksForClients(item.Blocks)
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockCover:
|
||||
normalizeRichBlockForClients(b.Cover)
|
||||
case *tg.PageBlockEmbedPost:
|
||||
normalizeRichBlocksForClients(b.Blocks)
|
||||
case *tg.PageBlockCollage:
|
||||
normalizeRichBlocksForClients(b.Items)
|
||||
case *tg.PageBlockSlideshow:
|
||||
normalizeRichBlocksForClients(b.Items)
|
||||
case *tg.PageBlockOrderedList:
|
||||
normalizeOrderedListForClients(b)
|
||||
case *tg.PageBlockDetails:
|
||||
normalizeRichBlocksForClients(b.Blocks)
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
normalizeRichBlocksForClients(b.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
|
||||
if list == nil {
|
||||
return
|
||||
}
|
||||
reversed := list.Reversed || list.Flags.Has(2)
|
||||
current := 1
|
||||
if list.Flags.Has(0) || list.Start != 0 {
|
||||
current = list.Start
|
||||
} else if reversed {
|
||||
current = len(list.Items)
|
||||
}
|
||||
step := 1
|
||||
if reversed {
|
||||
step = -1
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
value := current
|
||||
switch i := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
if v, ok := i.GetValue(); ok || i.Value != 0 {
|
||||
value = v
|
||||
if !ok {
|
||||
value = i.Value
|
||||
}
|
||||
}
|
||||
if num, ok := i.GetNum(); !ok || num == "" {
|
||||
i.SetNum(strconv.Itoa(value))
|
||||
}
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
if v, ok := i.GetValue(); ok || i.Value != 0 {
|
||||
value = v
|
||||
if !ok {
|
||||
value = i.Value
|
||||
}
|
||||
}
|
||||
if num, ok := i.GetNum(); !ok || num == "" {
|
||||
i.SetNum(strconv.Itoa(value))
|
||||
}
|
||||
normalizeRichBlocksForClients(i.Blocks)
|
||||
}
|
||||
current = value + step
|
||||
}
|
||||
}
|
||||
|
||||
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
|
||||
// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。
|
||||
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
|
||||
|
|
@ -62,9 +138,16 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
|
|||
// Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
if r.deps.Files == nil {
|
||||
if len(in.Blocks) == 0 {
|
||||
if len(in.Photos) == 0 && len(in.Documents) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
normalizeRichBlocksForClients(in.Blocks)
|
||||
blocks, err := encodeRichBlocks(in.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -132,3 +215,11 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustTGRichMessage(m *domain.MessageRichMessage) *tg.RichMessage {
|
||||
out, err := tgRichMessage(m)
|
||||
if err != nil {
|
||||
panic("invalid stored rich_message: " + err.Error())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDr
|
|||
}
|
||||
update := &tg.UpdateDraftMessage{
|
||||
Peer: peerTL,
|
||||
Draft: tgDraftMessageFromSaveDraft(req, date),
|
||||
Draft: tgDialogDraft(draft),
|
||||
}
|
||||
if draft.TopMessageID > 0 {
|
||||
update.SetTopMsgID(draft.TopMessageID)
|
||||
|
|
@ -170,6 +170,13 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
|
|||
if err != nil {
|
||||
return domain.DialogDraft{}, err
|
||||
}
|
||||
var richMessage *domain.MessageRichMessage
|
||||
if req.RichMessage != nil {
|
||||
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
|
||||
if err != nil {
|
||||
return domain.DialogDraft{}, err
|
||||
}
|
||||
}
|
||||
topMessageID := 0
|
||||
if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 {
|
||||
topMessageID = replyTo.TopMessageID
|
||||
|
|
@ -185,6 +192,7 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
|
|||
ReplyTo: replyTo,
|
||||
WebPage: webpage,
|
||||
Effect: req.Effect,
|
||||
RichMessage: richMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +242,8 @@ func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool {
|
|||
len(req.Entities) == 0 &&
|
||||
draftInputMedia(req.Media) == nil &&
|
||||
req.Effect == 0 &&
|
||||
req.SuggestedPost.Zero()
|
||||
req.SuggestedPost.Zero() &&
|
||||
req.RichMessage == nil
|
||||
}
|
||||
|
||||
func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,22 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if len(entities) > maxMessageEntityCount {
|
||||
return nil, entitiesTooLongErr()
|
||||
}
|
||||
} else {
|
||||
entities = nil
|
||||
}
|
||||
var richMessage *domain.MessageRichMessage
|
||||
hasRichMessage := req.RichMessage != nil
|
||||
if hasRichMessage {
|
||||
var richErr error
|
||||
richMessage, richErr = r.domainRichMessageFromInput(ctx, req.RichMessage)
|
||||
if richErr != nil {
|
||||
return nil, richErr
|
||||
}
|
||||
}
|
||||
if hasMessage && richMessage == nil {
|
||||
// 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
|
||||
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
|
||||
entities = augmentAutoEntities(message, entities)
|
||||
} else {
|
||||
entities = nil
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -50,7 +61,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, scheduleDate)
|
||||
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
|
||||
}
|
||||
if media, ok := req.GetMedia(); ok {
|
||||
// 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。
|
||||
|
|
@ -65,14 +76,15 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
return nil, mediaInvalidErr()
|
||||
}
|
||||
}
|
||||
if !hasMessage {
|
||||
_, hasMediaForContent := req.GetMedia()
|
||||
if !hasMessage && !hasRichMessage && !hasMediaForContent {
|
||||
return nil, messageEmptyErr()
|
||||
}
|
||||
if message == "" {
|
||||
if message == "" && richMessage == nil {
|
||||
// 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only
|
||||
// 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会
|
||||
// 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。
|
||||
if _, hasMedia := req.GetMedia(); !hasMedia {
|
||||
if !hasMediaForContent {
|
||||
return nil, messageEmptyErr()
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +119,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SetRichMessage: hasRichMessage,
|
||||
RichMessage: richMessage,
|
||||
EditDate: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -141,6 +155,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
OriginSessionID: sessionID,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: hasRichMessage,
|
||||
RichMessage: richMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, messageEditErr(err)
|
||||
|
|
|
|||
|
|
@ -4,19 +4,149 @@ import (
|
|||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appdialogs "telesrv/internal/app/dialogs"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。
|
||||
func richTextBlocks() []tg.PageBlockClass {
|
||||
return richTextBlocksWith("Rich Title", "First paragraph.")
|
||||
}
|
||||
|
||||
func richTextBlocksWith(title, paragraph string) []tg.PageBlockClass {
|
||||
return []tg.PageBlockClass{
|
||||
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "Rich Title"}},
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "First paragraph."}},
|
||||
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: title}},
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: paragraph}},
|
||||
}
|
||||
}
|
||||
|
||||
func richEmptyCaption() tg.PageCaption {
|
||||
return tg.PageCaption{
|
||||
Text: &tg.TextEmpty{},
|
||||
Credit: &tg.TextEmpty{},
|
||||
}
|
||||
}
|
||||
|
||||
func richOrderedListWithoutNums() []tg.PageBlockClass {
|
||||
return []tg.PageBlockClass{
|
||||
&tg.PageBlockOrderedList{
|
||||
Items: []tg.PageListOrderedItemClass{
|
||||
&tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}},
|
||||
&tg.PageListOrderedItemBlocks{
|
||||
Blocks: []tg.PageBlockClass{
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "two"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func richNestedOrderedListBlock() tg.PageBlockClass {
|
||||
return richOrderedListWithoutNums()[0]
|
||||
}
|
||||
|
||||
func assertOrderedListNums(t *testing.T, label string, blocks []tg.PageBlockClass, want ...string) {
|
||||
t.Helper()
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("%s: blocks = %d, want 1", label, len(blocks))
|
||||
}
|
||||
list, ok := blocks[0].(*tg.PageBlockOrderedList)
|
||||
if !ok {
|
||||
t.Fatalf("%s: block[0] = %T, want *tg.PageBlockOrderedList", label, blocks[0])
|
||||
}
|
||||
if len(list.Items) != len(want) {
|
||||
t.Fatalf("%s: items = %d, want %d", label, len(list.Items), len(want))
|
||||
}
|
||||
for idx, item := range list.Items {
|
||||
var (
|
||||
num string
|
||||
ok bool
|
||||
)
|
||||
switch i := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
num, ok = i.GetNum()
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
num, ok = i.GetNum()
|
||||
default:
|
||||
t.Fatalf("%s: item[%d] = %T, want ordered text/blocks", label, idx, item)
|
||||
}
|
||||
if !ok || num != want[idx] {
|
||||
t.Fatalf("%s: item[%d].num = %q, ok=%v, want %q", label, idx, num, ok, want[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectOrderedListNums(blocks []tg.PageBlockClass) []string {
|
||||
var nums []string
|
||||
var walk func(tg.PageBlockClass)
|
||||
walk = func(block tg.PageBlockClass) {
|
||||
switch b := block.(type) {
|
||||
case *tg.PageBlockList:
|
||||
for _, item := range b.Items {
|
||||
if item, ok := item.(*tg.PageListItemBlocks); ok {
|
||||
for _, child := range item.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
case *tg.PageBlockCover:
|
||||
walk(b.Cover)
|
||||
case *tg.PageBlockEmbedPost:
|
||||
for _, child := range b.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockCollage:
|
||||
for _, child := range b.Items {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockSlideshow:
|
||||
for _, child := range b.Items {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockDetails:
|
||||
for _, child := range b.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockBlockquoteBlocks:
|
||||
for _, child := range b.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
case *tg.PageBlockOrderedList:
|
||||
for _, item := range b.Items {
|
||||
switch i := item.(type) {
|
||||
case *tg.PageListOrderedItemText:
|
||||
if num, ok := i.GetNum(); ok {
|
||||
nums = append(nums, num)
|
||||
} else {
|
||||
nums = append(nums, "")
|
||||
}
|
||||
case *tg.PageListOrderedItemBlocks:
|
||||
if num, ok := i.GetNum(); ok {
|
||||
nums = append(nums, num)
|
||||
} else {
|
||||
nums = append(nums, "")
|
||||
}
|
||||
for _, child := range i.Blocks {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, block := range blocks {
|
||||
walk(block)
|
||||
}
|
||||
return nums
|
||||
}
|
||||
|
||||
// assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。
|
||||
func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
|
||||
t.Helper()
|
||||
|
|
@ -42,6 +172,198 @@ func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
|
|||
}
|
||||
}
|
||||
|
||||
func assertRichTitle(t *testing.T, label string, rich tg.RichMessage, want string) {
|
||||
t.Helper()
|
||||
if len(rich.Blocks) == 0 {
|
||||
t.Fatalf("%s: missing rich blocks", label)
|
||||
}
|
||||
title, ok := rich.Blocks[0].(*tg.PageBlockTitle)
|
||||
if !ok {
|
||||
t.Fatalf("%s: block[0] = %T, want *tg.PageBlockTitle", label, rich.Blocks[0])
|
||||
}
|
||||
if tp, ok := title.Text.(*tg.TextPlain); !ok || tp.Text != want {
|
||||
t.Fatalf("%s: title text = %+v, want plain %q", label, title.Text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
|
||||
Blocks: richOrderedListWithoutNums(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("domain rich message: %v", err)
|
||||
}
|
||||
got, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("tg rich message: %v", err)
|
||||
}
|
||||
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
|
||||
}
|
||||
|
||||
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{})
|
||||
if err != nil {
|
||||
t.Fatalf("empty input rich message: %v", err)
|
||||
}
|
||||
if rich != nil {
|
||||
t.Fatalf("empty input rich message = %+v, want nil", rich)
|
||||
}
|
||||
if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
|
||||
Photos: []tg.InputPhotoClass{&tg.InputPhoto{ID: 1, AccessHash: 2}},
|
||||
}); err == nil {
|
||||
t.Fatalf("orphan rich photos without blocks accepted")
|
||||
}
|
||||
if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
|
||||
Documents: []tg.InputDocumentClass{&tg.InputDocument{ID: 1, AccessHash: 2}},
|
||||
}); err == nil {
|
||||
t.Fatalf("orphan rich documents without blocks accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageNormalizesNestedOrderedListContainers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
caption := richEmptyCaption()
|
||||
blocks := []tg.PageBlockClass{
|
||||
&tg.PageBlockList{Items: []tg.PageListItemClass{
|
||||
&tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}},
|
||||
}},
|
||||
&tg.PageBlockCover{Cover: richNestedOrderedListBlock()},
|
||||
&tg.PageBlockEmbedPost{
|
||||
URL: "https://example.test/post",
|
||||
Author: "author",
|
||||
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
|
||||
Caption: caption,
|
||||
WebpageID: 1,
|
||||
},
|
||||
&tg.PageBlockCollage{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption},
|
||||
&tg.PageBlockSlideshow{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption},
|
||||
&tg.PageBlockDetails{
|
||||
Title: &tg.TextPlain{Text: "details"},
|
||||
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
|
||||
},
|
||||
&tg.PageBlockBlockquoteBlocks{
|
||||
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
|
||||
Caption: &tg.TextEmpty{},
|
||||
},
|
||||
}
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks})
|
||||
if err != nil {
|
||||
t.Fatalf("domain rich message: %v", err)
|
||||
}
|
||||
got, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("tg rich message: %v", err)
|
||||
}
|
||||
nums := collectOrderedListNums(got.Blocks)
|
||||
want := []string{"1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2"}
|
||||
if len(nums) != len(want) {
|
||||
t.Fatalf("ordered nums = %v, want %v", nums, want)
|
||||
}
|
||||
for i := range want {
|
||||
if nums[i] != want[i] {
|
||||
t.Fatalf("ordered nums = %v, want %v", nums, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageBlockFormatsEncodeDecode(t *testing.T) {
|
||||
caption := richEmptyCaption()
|
||||
blocks := []tg.PageBlockClass{
|
||||
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "title"}},
|
||||
&tg.PageBlockSubtitle{Text: &tg.TextPlain{Text: "subtitle"}},
|
||||
&tg.PageBlockAuthorDate{Author: &tg.TextPlain{Text: "author"}, PublishedDate: 1},
|
||||
&tg.PageBlockHeader{Text: &tg.TextPlain{Text: "header"}},
|
||||
&tg.PageBlockSubheader{Text: &tg.TextPlain{Text: "subheader"}},
|
||||
&tg.PageBlockParagraph{Text: &tg.TextConcat{Texts: []tg.RichTextClass{
|
||||
&tg.TextPlain{Text: "plain"},
|
||||
&tg.TextBold{Text: &tg.TextPlain{Text: "bold"}},
|
||||
&tg.TextItalic{Text: &tg.TextPlain{Text: "italic"}},
|
||||
&tg.TextUnderline{Text: &tg.TextPlain{Text: "underline"}},
|
||||
&tg.TextStrike{Text: &tg.TextPlain{Text: "strike"}},
|
||||
&tg.TextFixed{Text: &tg.TextPlain{Text: "fixed"}},
|
||||
&tg.TextSpoiler{Text: &tg.TextPlain{Text: "spoiler"}},
|
||||
&tg.TextURL{Text: &tg.TextPlain{Text: "url"}, URL: "https://example.test"},
|
||||
&tg.TextEmail{Text: &tg.TextPlain{Text: "email"}, Email: "a@example.test"},
|
||||
&tg.TextPhone{Text: &tg.TextPlain{Text: "phone"}, Phone: "+10000000000"},
|
||||
&tg.TextMath{Source: "x"},
|
||||
}}},
|
||||
&tg.PageBlockPreformatted{Text: &tg.TextPlain{Text: "pre"}, Language: "go"},
|
||||
&tg.PageBlockFooter{Text: &tg.TextPlain{Text: "footer"}},
|
||||
&tg.PageBlockDivider{},
|
||||
&tg.PageBlockAnchor{Name: "anchor"},
|
||||
&tg.PageBlockList{Items: []tg.PageListItemClass{
|
||||
&tg.PageListItemText{Text: &tg.TextPlain{Text: "item"}},
|
||||
&tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "nested"}},
|
||||
}},
|
||||
}},
|
||||
&tg.PageBlockBlockquote{Text: &tg.TextPlain{Text: "quote"}, Caption: &tg.TextEmpty{}},
|
||||
&tg.PageBlockPullquote{Text: &tg.TextPlain{Text: "pull"}, Caption: &tg.TextEmpty{}},
|
||||
&tg.PageBlockPhoto{PhotoID: 1, Caption: caption},
|
||||
&tg.PageBlockVideo{VideoID: 2, Caption: caption},
|
||||
&tg.PageBlockCover{Cover: &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "cover"}}},
|
||||
&tg.PageBlockEmbedPost{
|
||||
URL: "https://example.test/post",
|
||||
Author: "author",
|
||||
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "post"}}},
|
||||
Caption: caption,
|
||||
WebpageID: 3,
|
||||
},
|
||||
&tg.PageBlockCollage{Items: []tg.PageBlockClass{&tg.PageBlockPhoto{PhotoID: 4, Caption: caption}}, Caption: caption},
|
||||
&tg.PageBlockSlideshow{Items: []tg.PageBlockClass{&tg.PageBlockVideo{VideoID: 5, Caption: caption}}, Caption: caption},
|
||||
&tg.PageBlockAudio{AudioID: 6, Caption: caption},
|
||||
&tg.PageBlockKicker{Text: &tg.TextPlain{Text: "kicker"}},
|
||||
&tg.PageBlockTable{Title: &tg.TextPlain{Text: "table"}},
|
||||
&tg.PageBlockOrderedList{Items: []tg.PageListOrderedItemClass{
|
||||
&tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}},
|
||||
}},
|
||||
&tg.PageBlockDetails{Title: &tg.TextPlain{Text: "details"}, Blocks: []tg.PageBlockClass{
|
||||
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "inside"}},
|
||||
}},
|
||||
&tg.PageBlockRelatedArticles{Title: &tg.TextPlain{Text: "related"}, Articles: []tg.PageRelatedArticle{
|
||||
{URL: "https://example.test/a", WebpageID: 7},
|
||||
}},
|
||||
&tg.PageBlockMap{Geo: &tg.GeoPointEmpty{}, Zoom: 13, W: 64, H: 64, Caption: caption},
|
||||
&tg.PageBlockHeading1{Text: &tg.TextPlain{Text: "h1"}},
|
||||
&tg.PageBlockHeading2{Text: &tg.TextPlain{Text: "h2"}},
|
||||
&tg.PageBlockHeading3{Text: &tg.TextPlain{Text: "h3"}},
|
||||
&tg.PageBlockHeading4{Text: &tg.TextPlain{Text: "h4"}},
|
||||
&tg.PageBlockHeading5{Text: &tg.TextPlain{Text: "h5"}},
|
||||
&tg.PageBlockHeading6{Text: &tg.TextPlain{Text: "h6"}},
|
||||
&tg.PageBlockMath{Source: "x^2"},
|
||||
&tg.PageBlockThinking{Text: &tg.TextPlain{Text: "thinking"}},
|
||||
&tg.PageBlockBlockquoteBlocks{
|
||||
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "blocks"}}},
|
||||
Caption: &tg.TextEmpty{},
|
||||
},
|
||||
&tg.PageBlockUnsupported{},
|
||||
}
|
||||
ctx := context.Background()
|
||||
r := &Router{}
|
||||
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks})
|
||||
if err != nil {
|
||||
t.Fatalf("domain rich message: %v", err)
|
||||
}
|
||||
got, err := tgRichMessage(rich)
|
||||
if err != nil {
|
||||
t.Fatalf("tg rich message: %v", err)
|
||||
}
|
||||
if len(got.Blocks) != len(blocks) {
|
||||
t.Fatalf("blocks = %d, want %d", len(got.Blocks), len(blocks))
|
||||
}
|
||||
nums := collectOrderedListNums(got.Blocks)
|
||||
if len(nums) != 1 || nums[0] != "1" {
|
||||
t.Fatalf("ordered nums = %v, want [1]", nums)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的
|
||||
// blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。
|
||||
func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
|
||||
|
|
@ -95,6 +417,154 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
|
|||
assertRichTextBlocks(t, "getRichMessage", rich)
|
||||
}
|
||||
|
||||
// TestSendMessageRichOnlyTextBlocksRoundTrip 覆盖 TDesktop rich editor 的真实发送形态:
|
||||
// messages.sendMessage 带 f_rich_message,但 message:string 为空。
|
||||
func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, friend := newMediaTestRouter(t)
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
RandomID: 7101,
|
||||
RichMessage: &tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
Blocks: richTextBlocks(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send rich-only message: %v", err)
|
||||
}
|
||||
echo := newMessageFromUpdates(t, updates)
|
||||
if echo.Message != "" {
|
||||
t.Fatalf("rich-only echo message = %q, want empty fallback text", echo.Message)
|
||||
}
|
||||
rich, ok := echo.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("rich-only echo missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "rich-only echo", rich)
|
||||
}
|
||||
|
||||
func TestEditMessageRichOnlyPrivateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, friend := newMediaTestRouter(t)
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
RandomID: 7102,
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send rich-only message: %v", err)
|
||||
}
|
||||
msg := newMessageFromUpdates(t, updates)
|
||||
|
||||
editReq := &tg.MessagesEditMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
ID: msg.ID,
|
||||
}
|
||||
editReq.SetRichMessage(&tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
Blocks: richTextBlocksWith("Edited Title", "Edited paragraph."),
|
||||
})
|
||||
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
|
||||
if err != nil {
|
||||
t.Fatalf("edit rich-only private message: %v", err)
|
||||
}
|
||||
editedMsg := editMessageFromUpdates(t, edited)
|
||||
rich, ok := editedMsg.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("edited private message missing rich message")
|
||||
}
|
||||
assertRichTitle(t, "edited private", rich, "Edited Title")
|
||||
}
|
||||
|
||||
func TestChannelRichMessageSendEditHistoryRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, channel := newRichChannelTestRouter(t)
|
||||
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
|
||||
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: peer,
|
||||
RandomID: 7201,
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel rich-only message: %v", err)
|
||||
}
|
||||
echo := newMessageFromUpdates(t, updates)
|
||||
rich, ok := echo.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("channel echo missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "channel echo", rich)
|
||||
|
||||
historyList, err := r.deps.Channels.GetHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channel.ID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("channel get history: %v", err)
|
||||
}
|
||||
history := r.tgChannelHistoryMessages(WithUserID(ctx, owner.ID), owner.ID, historyList)
|
||||
stored := singleChannelStoredMessage(t, history)
|
||||
rich, ok = stored.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("channel history missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "channel history", rich)
|
||||
|
||||
editReq := &tg.MessagesEditMessageRequest{Peer: peer, ID: echo.ID}
|
||||
editReq.SetRichMessage(&tg.InputRichMessage{
|
||||
Rtl: true,
|
||||
Blocks: richTextBlocksWith("Edited Channel", "Edited channel paragraph."),
|
||||
})
|
||||
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
|
||||
if err != nil {
|
||||
t.Fatalf("edit channel rich-only message: %v", err)
|
||||
}
|
||||
editedMsg := editChannelMessageFromUpdates(t, edited)
|
||||
rich, ok = editedMsg.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("edited channel message missing rich message")
|
||||
}
|
||||
assertRichTitle(t, "edited channel", rich, "Edited Channel")
|
||||
}
|
||||
|
||||
func TestSaveDraftRichMessageRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, owner, friend := newRichDraftTestRouter(t)
|
||||
|
||||
ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("save rich draft = %v, %v", ok, err)
|
||||
}
|
||||
got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("get all drafts: %v", err)
|
||||
}
|
||||
updates := got.(*tg.Updates)
|
||||
if len(updates.Updates) != 1 {
|
||||
t.Fatalf("draft updates = %+v, want one", updates.Updates)
|
||||
}
|
||||
update, ok := updates.Updates[0].(*tg.UpdateDraftMessage)
|
||||
if !ok {
|
||||
t.Fatalf("draft update = %T", updates.Updates[0])
|
||||
}
|
||||
draft, ok := update.Draft.(*tg.DraftMessage)
|
||||
if !ok {
|
||||
t.Fatalf("draft = %T, want *tg.DraftMessage", update.Draft)
|
||||
}
|
||||
rich, ok := draft.GetRichMessage()
|
||||
if !ok {
|
||||
t.Fatalf("draft missing rich message")
|
||||
}
|
||||
assertRichTextBlocks(t, "draft", rich)
|
||||
}
|
||||
|
||||
// TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer
|
||||
// 拉取应返回 messageEmpty(不跨会话泄漏)。
|
||||
func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
|
||||
|
|
@ -182,3 +652,104 @@ func singleStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message
|
|||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func singleChannelStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message {
|
||||
t.Helper()
|
||||
box, ok := res.(*tg.MessagesChannelMessages)
|
||||
if !ok {
|
||||
t.Fatalf("channel messages = %T %+v, want messages.channelMessages", res, res)
|
||||
}
|
||||
var got *tg.Message
|
||||
for _, item := range box.Messages {
|
||||
msg, ok := item.(*tg.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("channel messages = %+v, want one regular message", box.Messages)
|
||||
}
|
||||
got = msg
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("channel messages = %+v, want one regular message", box.Messages)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func editMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
|
||||
t.Helper()
|
||||
upd, ok := updates.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
||||
}
|
||||
for _, u := range upd.Updates {
|
||||
if edit, ok := u.(*tg.UpdateEditMessage); ok {
|
||||
msg, ok := edit.Message.(*tg.Message)
|
||||
if !ok {
|
||||
t.Fatalf("edit message = %T, want *tg.Message", edit.Message)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatal("no updateEditMessage found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func editChannelMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
|
||||
t.Helper()
|
||||
upd, ok := updates.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
||||
}
|
||||
for _, u := range upd.Updates {
|
||||
if edit, ok := u.(*tg.UpdateEditChannelMessage); ok {
|
||||
msg, ok := edit.Message.(*tg.Message)
|
||||
if !ok {
|
||||
t.Fatalf("edit channel message = %T, want *tg.Message", edit.Message)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatal("no updateEditChannelMessage found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRichChannelTestRouter(t *testing.T) (*Router, domain.User, domain.Channel) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550009101", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelSvc := appchannels.NewService(channelStore)
|
||||
created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Rich Channel",
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create rich channel: %v", err)
|
||||
}
|
||||
dialogStore := memory.NewDialogStore()
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelSvc,
|
||||
Dialogs: appdialogs.NewService(dialogStore, channelStore),
|
||||
Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, owner, created.Channel
|
||||
}
|
||||
|
||||
func newRichDraftTestRouter(t *testing.T) (*Router, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550009201", FirstName: "Owner"})
|
||||
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550009202", FirstName: "Friend"})
|
||||
dialogStore := memory.NewDialogStore()
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Dialogs: appdialogs.NewService(dialogStore, memory.NewChannelStore()),
|
||||
Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, owner, friend
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ func (r *Router) scheduleOutgoing(ctx context.Context, userID int64, peer domain
|
|||
Message: p.message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, p.entities),
|
||||
Media: p.media,
|
||||
RichMessage: p.richMessage,
|
||||
Silent: p.silent,
|
||||
NoForwards: p.noforwards,
|
||||
ReplyTo: replyTo,
|
||||
|
|
@ -231,6 +232,7 @@ func (r *Router) sendClaimedScheduledMessages(ctx context.Context, userID int64,
|
|||
message: scheduled.Message,
|
||||
entities: tgInputMessageEntities(scheduled.Entities),
|
||||
media: scheduled.Media,
|
||||
richMessage: scheduled.RichMessage,
|
||||
silent: scheduled.Silent,
|
||||
noforwards: scheduled.NoForwards,
|
||||
})
|
||||
|
|
@ -340,6 +342,7 @@ func scheduledMessageAsDomainMessage(msg domain.ScheduledMessage, viewerUserID i
|
|||
ReplyTo: msg.ReplyTo,
|
||||
Forward: msg.Forward,
|
||||
Media: msg.Media,
|
||||
RichMessage: msg.RichMessage,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -427,7 +430,7 @@ func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, from
|
|||
return updates, nil
|
||||
}
|
||||
|
||||
func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, scheduleDate int) (tg.UpdatesClass, error) {
|
||||
func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, richMessage *domain.MessageRichMessage, setRichMessage bool, scheduleDate int) (tg.UpdatesClass, error) {
|
||||
if r.deps.Messages == nil {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
|
|
@ -446,6 +449,8 @@ func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer do
|
|||
SetMessage: setMessage,
|
||||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
SetRichMessage: setRichMessage,
|
||||
RichMessage: richMessage,
|
||||
ScheduleDate: scheduleDate,
|
||||
Date: now,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
defer func() {
|
||||
r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr)
|
||||
}()
|
||||
if req.Message == "" {
|
||||
sendErr = messageEmptyErr()
|
||||
return nil, messageEmptyErr()
|
||||
}
|
||||
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
|
||||
sendErr = messageTooLongErr()
|
||||
return nil, sendErr
|
||||
|
|
@ -101,6 +97,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
if req.Message == "" && richMessage == nil {
|
||||
sendErr = messageEmptyErr()
|
||||
return nil, sendErr
|
||||
}
|
||||
// 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端
|
||||
// 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。
|
||||
if richMessage == nil {
|
||||
|
|
@ -123,6 +123,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
replyToInput: req.ReplyTo,
|
||||
sendAsInput: req.SendAs,
|
||||
clearDraft: req.ClearDraft,
|
||||
richMessage: richMessage,
|
||||
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
||||
if err != nil {
|
||||
sendErr = err
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ type outgoingSend struct {
|
|||
replyMarkup *domain.MessageReplyMarkup
|
||||
viaBotID int64
|
||||
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
|
||||
// Phase 1 仅接入私聊;频道侧留 Phase 2。
|
||||
richMessage *domain.MessageRichMessage
|
||||
// groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染
|
||||
// 成一个相册组);单条发送恒 0。
|
||||
|
|
@ -92,6 +91,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
|
|||
ViaBotID: p.viaBotID,
|
||||
GroupedID: p.groupedID,
|
||||
ReplyMarkup: p.replyMarkup,
|
||||
RichMessage: p.richMessage,
|
||||
SendAs: sendAs,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import (
|
|||
)
|
||||
|
||||
func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
|
||||
// 空文本只在媒体替换(live location 续报/停止)时合法。
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 {
|
||||
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
|
|
@ -26,6 +25,17 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
|
|||
}
|
||||
prevMsg := s.messages[req.ChannelID][idx]
|
||||
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、
|
||||
// 不碰 body/entities/edit_date,事件为 channel_web_page。
|
||||
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 {
|
||||
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
|
||||
}
|
||||
pts := s.nextChannelPtsLocked(req.ChannelID)
|
||||
|
|
@ -80,6 +91,9 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
|
|||
if req.SetReplyMarkup {
|
||||
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
|
||||
}
|
||||
if req.SetRichMessage {
|
||||
msg.RichMessage = cloneRichMessage(req.RichMessage)
|
||||
}
|
||||
msg.EditDate = req.EditDate
|
||||
msg.Pts = pts
|
||||
s.messages[req.ChannelID][idx] = msg
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
|
|||
in.ReplyTo = cloneMessageReply(in.ReplyTo)
|
||||
in.Forward = cloneMessageForward(in.Forward)
|
||||
in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup)
|
||||
in.RichMessage = cloneRichMessage(in.RichMessage)
|
||||
in.Discussion = cloneChannelDiscussionRef(in.Discussion)
|
||||
in.Replies = cloneChannelMessageReplies(in.Replies)
|
||||
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
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
|
||||
}
|
||||
s.mu.Lock()
|
||||
|
|
@ -83,6 +83,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards,
|
||||
Body: req.Message,
|
||||
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},
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
|
|
@ -131,6 +132,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
RichMessage: cloneRichMessage(req.RichMessage),
|
||||
ReplyTo: replyTo,
|
||||
Forward: cloneMessageForward(req.Forward),
|
||||
ViaBotID: req.ViaBotID,
|
||||
|
|
|
|||
|
|
@ -840,6 +840,7 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
|
|||
webpage := *draft.WebPage
|
||||
draft.WebPage = &webpage
|
||||
}
|
||||
draft.RichMessage = cloneRichMessage(draft.RichMessage)
|
||||
return draft
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,10 +41,19 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
|
|||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
messageSenderID := target.From.ID
|
||||
|
|
@ -73,6 +82,9 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
|
|||
// 替换 markup(nil/空 = 清空键盘);双盒一致。
|
||||
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
|
||||
}
|
||||
if req.SetRichMessage {
|
||||
msg.RichMessage = cloneRichMessage(req.RichMessage)
|
||||
}
|
||||
msg.EditDate = req.EditDate
|
||||
msg.HideEdited = req.HideEdited
|
||||
msg.Pts = s.nextPtsLocked(userID)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"reflect"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -118,6 +119,13 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
|||
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 {
|
||||
if reply == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ import (
|
|||
"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) {
|
||||
ctx := context.Background()
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ import (
|
|||
)
|
||||
|
||||
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 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 {
|
||||
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
|
|
@ -31,6 +30,10 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh
|
|||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
// 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts +
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
ptsCount := 1
|
||||
|
|
@ -130,9 +145,10 @@ SET body = $4,
|
|||
edit_date = $6,
|
||||
pts = $7,
|
||||
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()
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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.Pts = editPts
|
||||
event := domain.ChannelUpdateEvent{
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
|
|||
var entities, reply, forward, action string
|
||||
var mediaJSON string
|
||||
var replyMarkupJSON string
|
||||
var richMessageJSON string
|
||||
var savedPeerType string
|
||||
var savedPeerID int64
|
||||
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,
|
||||
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
|
||||
&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 {
|
||||
return domain.ChannelMessage{}, err
|
||||
}
|
||||
|
|
@ -65,6 +66,10 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
|
|||
if err != nil {
|
||||
return domain.ChannelMessage{}, err
|
||||
}
|
||||
msg.RichMessage, err = decodeRichMessage(richMessageJSON)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +87,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
|
|||
var count int
|
||||
var mediaJSON string
|
||||
var replyMarkupJSON string
|
||||
var richMessageJSON string
|
||||
var savedPeerType string
|
||||
var savedPeerID int64
|
||||
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,
|
||||
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
|
||||
&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 {
|
||||
return domain.ChannelMessage{}, 0, err
|
||||
}
|
||||
|
|
@ -117,6 +123,10 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
|
|||
if err != nil {
|
||||
return domain.ChannelMessage{}, 0, err
|
||||
}
|
||||
msg.RichMessage, err = decodeRichMessage(richMessageJSON)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, 0, err
|
||||
}
|
||||
return msg, count, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
)
|
||||
|
||||
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
|
||||
}
|
||||
if req.Date == 0 {
|
||||
|
|
@ -123,6 +123,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
|
|||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
RichMessage: req.RichMessage,
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
ReplyMarkup: req.ReplyMarkup,
|
||||
|
|
@ -180,6 +181,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
|
|||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
RichMessage: req.RichMessage,
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
ReplyMarkup: req.ReplyMarkup,
|
||||
|
|
@ -422,6 +424,10 @@ func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMe
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
richMessage, err := encodeRichMessage(msg.RichMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sendAsType sql.NullString
|
||||
var sendAsID sql.NullInt64
|
||||
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,
|
||||
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,
|
||||
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
|
||||
) 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)`,
|
||||
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,$37)`,
|
||||
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,
|
||||
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)
|
||||
}
|
||||
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
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,
|
||||
title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -93,6 +94,13 @@ func decodeRichMessage(s string) (*domain.MessageRichMessage, error) {
|
|||
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) {
|
||||
if s == "" || s == "[]" || s == "null" {
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -54,17 +54,26 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe
|
|||
PeerType: string(req.Peer.Type),
|
||||
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 errors.Is(err, pgx.ErrNoRows) {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
|
||||
if err != nil {
|
||||
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
|
||||
boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||
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,
|
||||
SetReplyMarkup: req.SetReplyMarkup,
|
||||
ReplyMarkupJson: replyMarkupJSON,
|
||||
SetRichMessage: req.SetRichMessage,
|
||||
RichMessageJson: richMessageJSON,
|
||||
}); err != nil {
|
||||
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),
|
||||
SetReplyMarkup: req.SetReplyMarkup,
|
||||
ReplyMarkupJson: replyMarkupJSON,
|
||||
SetRichMessage: req.SetRichMessage,
|
||||
RichMessageJson: richMessageJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("update message box edit: %w", err)
|
||||
|
|
|
|||
|
|
@ -1036,6 +1036,10 @@ SET body = sqlc.arg(body)::text,
|
|||
reply_markup = CASE
|
||||
WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb
|
||||
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
|
||||
WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint
|
||||
AND id = sqlc.arg(private_message_id)::bigint;
|
||||
|
|
@ -1050,6 +1054,10 @@ SET body = sqlc.arg(body)::text,
|
|||
reply_markup = CASE
|
||||
WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb
|
||||
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
|
||||
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
|
||||
AND box_id = sqlc.arg(box_id)::int
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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")
|
||||
}
|
||||
if req.Date == 0 {
|
||||
|
|
@ -41,6 +41,10 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
|
|||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
|
|
@ -87,7 +91,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
|
|||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO scheduled_messages (
|
||||
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,
|
||||
quote_text, quote_entities, quote_offset,
|
||||
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
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8::jsonb, $9::jsonb, $10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17::jsonb, $18,
|
||||
$19, $20, $21, $22,
|
||||
$23, $24,
|
||||
$25, $26, 'pending', $27, $27
|
||||
$7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12,
|
||||
$13, $14, $15, $16,
|
||||
$17, $18::jsonb, $19,
|
||||
$20, $21, $22, $23,
|
||||
$24, $25,
|
||||
$26, $27, 'pending', $28, $28
|
||||
)`, 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.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset,
|
||||
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
|
||||
entities := append([]domain.MessageEntity(nil), current.Entities...)
|
||||
richMessage := current.RichMessage
|
||||
if req.SetMessage {
|
||||
if req.Message == "" && current.Media.IsZero() {
|
||||
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
|
||||
}
|
||||
message = req.Message
|
||||
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)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
encodedRichMessage, err := encodeRichMessage(richMessage)
|
||||
if err != nil {
|
||||
return domain.ScheduledMessage{}, err
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE scheduled_messages
|
||||
SET body = $5,
|
||||
entities = $6::jsonb,
|
||||
schedule_date = $7,
|
||||
updated_at = $8
|
||||
rich_message = $7::jsonb,
|
||||
schedule_date = $8,
|
||||
updated_at = $9
|
||||
WHERE owner_user_id = $1
|
||||
AND peer_type = $2
|
||||
AND peer_id = $3
|
||||
AND scheduled_id = $4
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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,
|
||||
quote_text, quote_entities::text, quote_offset,
|
||||
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
|
||||
|
|
@ -486,7 +499,7 @@ func scheduledMessageSelectColumnsFor(alias string) string {
|
|||
}
|
||||
prefix := alias + "."
|
||||
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 + `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,
|
||||
|
|
@ -516,6 +529,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
|
|||
peerType string
|
||||
entitiesJSON string
|
||||
mediaJSON string
|
||||
richMessageJSON string
|
||||
replyToMsgID int32
|
||||
replyToPeerType string
|
||||
replyToPeerID int64
|
||||
|
|
@ -533,7 +547,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
|
|||
)
|
||||
if err := scanner.Scan(
|
||||
&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,
|
||||
"eText, "eEntitiesJSON, "eOffset,
|
||||
&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)
|
||||
}
|
||||
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 实时
|
||||
// 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。
|
||||
_, _, reply, forward, err := messageMetadataFromFields(
|
||||
|
|
|
|||
|
|
@ -107,4 +107,72 @@ func TestScheduledMessageEditPreservesContentWhenMessageUnset(t *testing.T) {
|
|||
if !errors.Is(err, domain.ErrMessageEmpty) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3554,9 +3554,13 @@ SET body = $1::text,
|
|||
reply_markup = CASE
|
||||
WHEN $6::boolean THEN $7::jsonb
|
||||
ELSE reply_markup
|
||||
END,
|
||||
rich_message = CASE
|
||||
WHEN $8::boolean THEN $9::jsonb
|
||||
ELSE rich_message
|
||||
END
|
||||
WHERE owner_user_id = $8::bigint
|
||||
AND box_id = $9::int
|
||||
WHERE owner_user_id = $10::bigint
|
||||
AND box_id = $11::int
|
||||
AND NOT deleted
|
||||
RETURNING
|
||||
box_id,
|
||||
|
|
@ -3613,6 +3617,8 @@ type UpdateMessageBoxEditParams struct {
|
|||
Pts int32
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkupJson []byte
|
||||
SetRichMessage bool
|
||||
RichMessageJson []byte
|
||||
OwnerUserID int64
|
||||
BoxID int32
|
||||
}
|
||||
|
|
@ -3673,6 +3679,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox
|
|||
arg.Pts,
|
||||
arg.SetReplyMarkup,
|
||||
arg.ReplyMarkupJson,
|
||||
arg.SetRichMessage,
|
||||
arg.RichMessageJson,
|
||||
arg.OwnerUserID,
|
||||
arg.BoxID,
|
||||
)
|
||||
|
|
@ -3735,9 +3743,13 @@ SET body = $1::text,
|
|||
reply_markup = CASE
|
||||
WHEN $5::boolean THEN $6::jsonb
|
||||
ELSE reply_markup
|
||||
END,
|
||||
rich_message = CASE
|
||||
WHEN $7::boolean THEN $8::jsonb
|
||||
ELSE rich_message
|
||||
END
|
||||
WHERE sender_user_id = $7::bigint
|
||||
AND id = $8::bigint
|
||||
WHERE sender_user_id = $9::bigint
|
||||
AND id = $10::bigint
|
||||
`
|
||||
|
||||
type UpdatePrivateMessageEditParams struct {
|
||||
|
|
@ -3747,6 +3759,8 @@ type UpdatePrivateMessageEditParams struct {
|
|||
HideEdited bool
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkupJson []byte
|
||||
SetRichMessage bool
|
||||
RichMessageJson []byte
|
||||
SenderUserID int64
|
||||
PrivateMessageID int64
|
||||
}
|
||||
|
|
@ -3759,6 +3773,8 @@ func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivat
|
|||
arg.HideEdited,
|
||||
arg.SetReplyMarkup,
|
||||
arg.ReplyMarkupJson,
|
||||
arg.SetRichMessage,
|
||||
arg.RichMessageJson,
|
||||
arg.SenderUserID,
|
||||
arg.PrivateMessageID,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue