feat: sync rich text message support

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

View file

@ -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{

View file

@ -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
}

View file

@ -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 媒体标签页。

View file

@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body,
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,

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -27,7 +27,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel {
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,
&quoteText, &quoteEntitiesJSON, &quoteOffset,
&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 直发归 selffwd saved 维度恒空。
_, _, reply, forward, err := messageMetadataFromFields(

View file

@ -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)
}
}

View file

@ -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,
)