fix: preserve formatted date message entities

This commit is contained in:
A 2026-07-05 20:42:13 +08:00
parent 53105b6823
commit 03b785ebf4
10 changed files with 214 additions and 31 deletions

View file

@ -89,6 +89,52 @@ func TestTranscodeMessageGolden(t *testing.T) {
}
}
func TestTranscodeFormattedDateEntityLayerBoundary(t *testing.T) {
const formattedDateEntityCRC = 0x904ac7c7
entityCRC := func(crc uint32) []byte {
return []byte{byte(crc), byte(crc >> 8), byte(crc >> 16), byte(crc >> 24)}
}
msg := &tg.Message{
ID: 7,
PeerID: &tg.PeerUser{UserID: 2},
Date: 100,
Message: "Meet soon",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityFormattedDate{Offset: 5, Length: 4, Date: 1773436800, ShortDate: true, ShortTime: true},
},
}
raw := mustEncode(t, msg)
out222, err := Transcode(raw, 222)
if err != nil {
t.Fatalf("transcode message->222: %v", err)
}
if bytes.Contains(out222, entityCRC(formattedDateEntityCRC)) {
t.Fatalf("layer 222 output leaked formatted-date entity")
}
if !bytes.Contains(out222, entityCRC(messageEntityUnknownID)) {
t.Fatalf("layer 222 output missing messageEntityUnknown fallback")
}
m222 := loadLayerModel(t, 222)
b222 := &bin.Buffer{Buf: append([]byte(nil), out222...)}
if err := m222.skipObject(b222); err != nil || b222.Len() != 0 {
t.Fatalf("layer 222 formatted-date fallback does not parse cleanly (err=%v left=%d)", err, b222.Len())
}
out223, err := Transcode(raw, 223)
if err != nil {
t.Fatalf("transcode message->223: %v", err)
}
if !bytes.Contains(out223, entityCRC(formattedDateEntityCRC)) {
t.Fatalf("layer 223 output did not preserve formatted-date entity")
}
m223 := loadLayerModel(t, 223)
b223 := &bin.Buffer{Buf: append([]byte(nil), out223...)}
if err := m223.skipObject(b223); err != nil || b223.Len() != 0 {
t.Fatalf("layer 223 formatted-date output does not parse cleanly (err=%v left=%d)", err, b223.Len())
}
}
// TestTranscodePassthroughNonAPI verifies that a top-level constructor absent
// from the tg schema (an MTProto control object such as rpc_error) passes
// through untouched at any layer.

View file

@ -4,28 +4,29 @@ package domain
type MessageEntityType string
const (
MessageEntityBold MessageEntityType = "bold"
MessageEntityItalic MessageEntityType = "italic"
MessageEntityUnderline MessageEntityType = "underline"
MessageEntityStrike MessageEntityType = "strike"
MessageEntityCode MessageEntityType = "code"
MessageEntityPre MessageEntityType = "pre"
MessageEntityTextURL MessageEntityType = "text_url"
MessageEntityMentionName MessageEntityType = "mention_name"
MessageEntitySpoiler MessageEntityType = "spoiler"
MessageEntityBlockquote MessageEntityType = "blockquote"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
MessageEntityMention MessageEntityType = "mention"
MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityURL MessageEntityType = "url"
MessageEntityEmail MessageEntityType = "email"
MessageEntityPhone MessageEntityType = "phone"
MessageEntityBankCard MessageEntityType = "bank_card"
MessageEntityDiffInsert MessageEntityType = "diff_insert"
MessageEntityDiffReplace MessageEntityType = "diff_replace"
MessageEntityDiffDelete MessageEntityType = "diff_delete"
MessageEntityBold MessageEntityType = "bold"
MessageEntityItalic MessageEntityType = "italic"
MessageEntityUnderline MessageEntityType = "underline"
MessageEntityStrike MessageEntityType = "strike"
MessageEntityCode MessageEntityType = "code"
MessageEntityPre MessageEntityType = "pre"
MessageEntityTextURL MessageEntityType = "text_url"
MessageEntityMentionName MessageEntityType = "mention_name"
MessageEntitySpoiler MessageEntityType = "spoiler"
MessageEntityBlockquote MessageEntityType = "blockquote"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
MessageEntityMention MessageEntityType = "mention"
MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityURL MessageEntityType = "url"
MessageEntityEmail MessageEntityType = "email"
MessageEntityPhone MessageEntityType = "phone"
MessageEntityBankCard MessageEntityType = "bank_card"
MessageEntityFormattedDate MessageEntityType = "formatted_date"
MessageEntityDiffInsert MessageEntityType = "diff_insert"
MessageEntityDiffReplace MessageEntityType = "diff_replace"
MessageEntityDiffDelete MessageEntityType = "diff_delete"
)
const (
@ -104,6 +105,15 @@ type MessageEntity struct {
DocumentID int64
// Collapsed 仅 blockquote 使用。
Collapsed bool
// Date / Relative / ShortTime / LongTime / ShortDate / LongDate / DayOfWeek
// 仅 formatted_date 使用。
Date int
Relative bool
ShortTime bool
LongTime bool
ShortDate bool
LongDate bool
DayOfWeek bool
// OldText 仅 AI compose diff_replace 使用;普通消息输入不会接受该实体。
OldText string
}

View file

@ -473,6 +473,18 @@ func tgMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass
out = append(out, &tg.MessageEntityPhone{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityBankCard:
out = append(out, &tg.MessageEntityBankCard{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityFormattedDate:
out = append(out, &tg.MessageEntityFormattedDate{
Offset: entity.Offset,
Length: entity.Length,
Date: entity.Date,
Relative: entity.Relative,
ShortTime: entity.ShortTime,
LongTime: entity.LongTime,
ShortDate: entity.ShortDate,
LongDate: entity.LongDate,
DayOfWeek: entity.DayOfWeek,
})
case domain.MessageEntityDiffInsert:
out = append(out, &tg.MessageEntityDiffInsert{Offset: entity.Offset, Length: entity.Length})
case domain.MessageEntityDiffReplace:
@ -549,6 +561,19 @@ func domainMessageEntitiesForViewer(viewerUserID int64, entities []tg.MessageEnt
out = append(out, domain.MessageEntity{Type: domain.MessageEntityPhone, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityBankCard:
out = append(out, domain.MessageEntity{Type: domain.MessageEntityBankCard, Offset: e.Offset, Length: e.Length})
case *tg.MessageEntityFormattedDate:
out = append(out, domain.MessageEntity{
Type: domain.MessageEntityFormattedDate,
Offset: e.Offset,
Length: e.Length,
Date: e.Date,
Relative: e.Relative || e.GetRelative(),
ShortTime: e.ShortTime || e.GetShortTime(),
LongTime: e.LongTime || e.GetLongTime(),
ShortDate: e.ShortDate || e.GetShortDate(),
LongDate: e.LongDate || e.GetLongDate(),
DayOfWeek: e.DayOfWeek || e.GetDayOfWeek(),
})
}
}
return out

View file

@ -3,6 +3,7 @@ package rpc
import (
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
@ -116,6 +117,7 @@ func TestMessageEntitiesRoundTripAllStyledTypes(t *testing.T) {
&tg.MessageEntityMention{Offset: 12, Length: 13},
&tg.MessageEntityHashtag{Offset: 13, Length: 14},
&tg.MessageEntityURL{Offset: 14, Length: 15},
&tg.MessageEntityFormattedDate{Offset: 15, Length: 16, Date: 1773436800, ShortTime: true, LongDate: true, DayOfWeek: true},
}
converted := domainMessageEntitiesForViewer(viewerID, in)
if len(converted) != len(in) {
@ -133,6 +135,9 @@ func TestMessageEntitiesRoundTripAllStyledTypes(t *testing.T) {
if converted[11].DocumentID != 777 {
t.Fatalf("custom emoji document = %d, want 777", converted[11].DocumentID)
}
if formatted := converted[15]; formatted.Type != domain.MessageEntityFormattedDate || formatted.Date != 1773436800 || !formatted.ShortTime || !formatted.LongDate || !formatted.DayOfWeek {
t.Fatalf("formatted date entity = %+v, want date and flags preserved", formatted)
}
out := tgMessageEntities(converted)
if len(out) != len(in) {
t.Fatalf("round-trip produced %d entities, want %d", len(out), len(in))
@ -143,4 +148,22 @@ func TestMessageEntitiesRoundTripAllStyledTypes(t *testing.T) {
if mention, ok := out[8].(*tg.MessageEntityMentionName); !ok || mention.UserID != viewerID {
t.Fatalf("self mention round-trip = %#v, want messageEntityMentionName self", out[8])
}
if formatted, ok := out[15].(*tg.MessageEntityFormattedDate); !ok || formatted.Date != 1773436800 || !formatted.ShortTime || !formatted.LongDate || !formatted.DayOfWeek {
t.Fatalf("formatted date round-trip = %#v, want date and flags preserved", out[15])
}
}
func TestMessageEntityFormattedDatePreservesFlagsOnlyInput(t *testing.T) {
var flags bin.Fields
flags.Set(1) // short_time
flags.Set(4) // long_date
converted := domainMessageEntities([]tg.MessageEntityClass{
&tg.MessageEntityFormattedDate{Flags: flags, Offset: 1, Length: 2, Date: 1773436800},
})
if len(converted) != 1 {
t.Fatalf("converted %d entities, want one", len(converted))
}
if got := converted[0]; got.Type != domain.MessageEntityFormattedDate || got.Date != 1773436800 || !got.ShortTime || !got.LongDate {
t.Fatalf("formatted date flags-only input = %+v, want short_time/long_date preserved", got)
}
}

View file

@ -29,6 +29,7 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
RandomID: 123456,
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 5},
&tg.MessageEntityFormattedDate{Offset: 6, Length: 8, Date: 1773436800, ShortDate: true, ShortTime: true},
},
}
var in bin.Buffer
@ -51,8 +52,12 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
if messages.sendUserID != sender.ID || messages.sendReq.SenderUserID != sender.ID || messages.sendReq.RecipientUserID != recipient.ID || messages.sendReq.OriginSessionID != 77 {
t.Fatalf("send context = user %d req %+v, want sender/recipient/session", messages.sendUserID, messages.sendReq)
}
if len(messages.sendReq.Entities) != 1 || messages.sendReq.Entities[0].Type != domain.MessageEntityBold {
t.Fatalf("entities = %+v, want bold entity converted to domain", messages.sendReq.Entities)
if len(messages.sendReq.Entities) != 2 || messages.sendReq.Entities[0].Type != domain.MessageEntityBold {
t.Fatalf("entities = %+v, want bold and formatted date converted to domain", messages.sendReq.Entities)
}
dateEntity := messages.sendReq.Entities[1]
if dateEntity.Type != domain.MessageEntityFormattedDate || dateEntity.Date != 1773436800 || !dateEntity.ShortDate || !dateEntity.ShortTime {
t.Fatalf("formatted date entity = %+v, want date and flags preserved", dateEntity)
}
if len(got.Updates) != 2 {
t.Fatalf("updates = %+v, want message id + new message", got.Updates)
@ -68,6 +73,13 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
if !ok || !msg.Out || msg.PeerID.(*tg.PeerUser).UserID != recipient.ID || msg.Message != req.Message {
t.Fatalf("message = %#v, want outgoing private text to recipient", newMsg.Message)
}
if len(msg.Entities) != 2 {
t.Fatalf("message entities = %+v, want 2", msg.Entities)
}
formatted, ok := msg.Entities[1].(*tg.MessageEntityFormattedDate)
if !ok || formatted.Date != 1773436800 || !formatted.ShortDate || !formatted.ShortTime {
t.Fatalf("formatted response entity = %#v, want date and flags preserved", msg.Entities[1])
}
if metrics.messageSend != 1 || metrics.messageSendErr != nil {
t.Fatalf("metrics send=%d err=%v, want one successful send", metrics.messageSend, metrics.messageSendErr)
}

View file

@ -1481,7 +1481,8 @@ func storyCaptionEntitySupported(entity tg.MessageEntityClass) bool {
*tg.MessageEntityBankCard,
*tg.MessageEntitySpoiler,
*tg.MessageEntityCustomEmoji,
*tg.MessageEntityBlockquote:
*tg.MessageEntityBlockquote,
*tg.MessageEntityFormattedDate:
return true
default:
return false

View file

@ -7048,8 +7048,11 @@ func TestStoriesCaptionEntitiesRejectMalformedInputsBeforePeerAndMutation(t *tes
PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}},
RandomID: 7320,
Period: 86400,
Caption: "🙂 ok",
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 2}},
Caption: "🙂 ok soon",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 2},
&tg.MessageEntityFormattedDate{Offset: 6, Length: 4, Date: 1773436800, ShortDate: true, ShortTime: true},
},
}
updates, err := r.onStoriesSendStory(reqCtx, validReq)
if err != nil {
@ -7057,12 +7060,15 @@ func TestStoriesCaptionEntitiesRejectMalformedInputsBeforePeerAndMutation(t *tes
}
item := updates.(*tg.Updates).Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem)
entities, ok := item.GetEntities()
if !ok || len(entities) != 1 {
t.Fatalf("story entities = ok %v %+v, want one entity", ok, entities)
if !ok || len(entities) != 2 {
t.Fatalf("story entities = ok %v %+v, want two entities", ok, entities)
}
if bold, ok := entities[0].(*tg.MessageEntityBold); !ok || bold.Offset != 0 || bold.Length != 2 {
t.Fatalf("story entity = %T %+v, want bold offset 0 length 2", entities[0], entities[0])
}
if formatted, ok := entities[1].(*tg.MessageEntityFormattedDate); !ok || formatted.Offset != 6 || formatted.Length != 4 || formatted.Date != 1773436800 || !formatted.ShortDate || !formatted.ShortTime {
t.Fatalf("story entity = %T %+v, want formatted date preserved", entities[1], entities[1])
}
storyID := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID
var typedNilBold *tg.MessageEntityBold
@ -7139,7 +7145,7 @@ func TestStoriesCaptionEntitiesRejectMalformedInputsBeforePeerAndMutation(t *tes
t.Fatalf("stories after rejected caption entities = %+v, want original story only", stories.Stories.Stories)
}
stored := stories.Stories.Stories[0].(*tg.StoryItem)
if stored.Caption != "🙂 ok" || stored.Edited {
if stored.Caption != validReq.Caption || stored.Edited {
t.Fatalf("story after rejected caption entities = %+v, want original caption and not edited", stored)
}
}

View file

@ -243,6 +243,13 @@ type messageEntityJSON struct {
Language string `json:"language,omitempty"`
DocumentID int64 `json:"document_id,omitempty"`
Collapsed bool `json:"collapsed,omitempty"`
Date int `json:"date,omitempty"`
Relative bool `json:"relative,omitempty"`
ShortTime bool `json:"short_time,omitempty"`
LongTime bool `json:"long_time,omitempty"`
ShortDate bool `json:"short_date,omitempty"`
LongDate bool `json:"long_date,omitempty"`
DayOfWeek bool `json:"day_of_week,omitempty"`
}
func encodeMessageEntities(entities []domain.MessageEntity) ([]byte, error) {
@ -260,6 +267,13 @@ func encodeMessageEntities(entities []domain.MessageEntity) ([]byte, error) {
Language: entity.Language,
DocumentID: entity.DocumentID,
Collapsed: entity.Collapsed,
Date: entity.Date,
Relative: entity.Relative,
ShortTime: entity.ShortTime,
LongTime: entity.LongTime,
ShortDate: entity.ShortDate,
LongDate: entity.LongDate,
DayOfWeek: entity.DayOfWeek,
})
}
raw, err := json.Marshal(wire)
@ -288,6 +302,13 @@ func decodeMessageEntities(raw string) ([]domain.MessageEntity, error) {
Language: entity.Language,
DocumentID: entity.DocumentID,
Collapsed: entity.Collapsed,
Date: entity.Date,
Relative: entity.Relative,
ShortTime: entity.ShortTime,
LongTime: entity.LongTime,
ShortDate: entity.ShortDate,
LongDate: entity.LongDate,
DayOfWeek: entity.DayOfWeek,
})
}
return out, nil

View file

@ -0,0 +1,33 @@
package postgres
import (
"testing"
"telesrv/internal/domain"
)
func TestMessageEntityJSONRoundTripPreservesFormattedDate(t *testing.T) {
in := []domain.MessageEntity{
{
Type: domain.MessageEntityFormattedDate,
Offset: 2,
Length: 5,
Date: 1773436800,
Relative: true,
ShortTime: true,
LongDate: true,
DayOfWeek: true,
},
}
raw, err := encodeMessageEntities(in)
if err != nil {
t.Fatalf("encodeMessageEntities: %v", err)
}
out, err := decodeMessageEntities(string(raw))
if err != nil {
t.Fatalf("decodeMessageEntities: %v", err)
}
if !sameMessageEntities(in, out) {
t.Fatalf("round-trip entities = %+v raw=%s, want %+v", out, raw, in)
}
}

View file

@ -43,7 +43,10 @@ func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) {
RecipientUserID: recipient.ID,
RandomID: 123456,
Message: "hello from pg",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}},
Entities: []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 5},
{Type: domain.MessageEntityFormattedDate, Offset: 6, Length: 4, Date: 1773436800, ShortTime: true, LongDate: true},
},
Date: 1700000200,
OriginAuthKeyID: originAuthKeyID,
OriginSessionID: 77,
@ -73,6 +76,9 @@ func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) {
if len(senderHistory.Messages) != 1 || len(recipientHistory.Messages) != 1 {
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
}
if !sameMessageEntities(senderHistory.Messages[0].Entities, req.Entities) || !sameMessageEntities(recipientHistory.Messages[0].Entities, req.Entities) {
t.Fatalf("history entities sender=%+v recipient=%+v, want %+v", senderHistory.Messages[0].Entities, recipientHistory.Messages[0].Entities, req.Entities)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {