diff --git a/internal/compat/layerwire/transcode_test.go b/internal/compat/layerwire/transcode_test.go index abf43408..a2597970 100644 --- a/internal/compat/layerwire/transcode_test.go +++ b/internal/compat/layerwire/transcode_test.go @@ -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. diff --git a/internal/domain/message.go b/internal/domain/message.go index 3bbfb56b..5b23f5a9 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -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 } diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 8374624a..cee1b6e3 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -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 diff --git a/internal/rpc/convert_test.go b/internal/rpc/convert_test.go index 4b6d100c..0e3f9916 100644 --- a/internal/rpc/convert_test.go +++ b/internal/rpc/convert_test.go @@ -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) + } } diff --git a/internal/rpc/messages_send_rpc_test.go b/internal/rpc/messages_send_rpc_test.go index bbd76bdb..6804f38c 100644 --- a/internal/rpc/messages_send_rpc_test.go +++ b/internal/rpc/messages_send_rpc_test.go @@ -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) } diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index f81a1c4e..223ee8bd 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -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 diff --git a/internal/rpc/stories_rpc_test.go b/internal/rpc/stories_rpc_test.go index 0cda92d5..565591b9 100644 --- a/internal/rpc/stories_rpc_test.go +++ b/internal/rpc/stories_rpc_test.go @@ -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) } } diff --git a/internal/store/postgres/message_helpers.go b/internal/store/postgres/message_helpers.go index 538a9c81..e852f9d6 100644 --- a/internal/store/postgres/message_helpers.go +++ b/internal/store/postgres/message_helpers.go @@ -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 diff --git a/internal/store/postgres/message_helpers_test.go b/internal/store/postgres/message_helpers_test.go new file mode 100644 index 00000000..d27017ed --- /dev/null +++ b/internal/store/postgres/message_helpers_test.go @@ -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) + } +} diff --git a/internal/store/postgres/message_send_integration_test.go b/internal/store/postgres/message_send_integration_test.go index 773773af..4ec9cb33 100644 --- a/internal/store/postgres/message_send_integration_test.go +++ b/internal/store/postgres/message_send_integration_test.go @@ -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 {