From 6a131d2d47d41c1461acdec79dfb5578585c922c Mon Sep 17 00:00:00 2001 From: fluffur Date: Thu, 18 Jun 2026 14:35:14 +0500 Subject: [PATCH 1/2] feat: add date time message entity --- codec_misc.go | 24 ++++++++++++++++++++++++ entities.go | 28 ++++++++++++++++++++++++++++ enums.go | 1 + types_message.go | 16 +++++++++------- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/codec_misc.go b/codec_misc.go index bdb934a..4ef856d 100644 --- a/codec_misc.go +++ b/codec_misc.go @@ -32,6 +32,16 @@ func (s *MessageEntity) Encode(e *jx.Encoder) { e.Str(s.CustomEmojiID) } + if s.UnixTime != 0 { + e.FieldStart("unix_time") + e.Int(s.UnixTime) + } + + if s.DateTimeFormat != "" { + e.FieldStart("date_time_format") + e.Str(s.DateTimeFormat) + } + e.ObjEnd() } @@ -88,6 +98,20 @@ func (s *MessageEntity) Decode(d *jx.Decoder) error { } s.CustomEmojiID = v + case "unix_time": + v, err := d.Int() + if err != nil { + return err + } + + s.UnixTime = v + case "date_time_format": + v, err := d.Str() + if err != nil { + return err + } + + s.DateTimeFormat = v default: return d.Skip() } diff --git a/entities.go b/entities.go index 1d8b5fd..9dafbed 100644 --- a/entities.go +++ b/entities.go @@ -2,6 +2,7 @@ package botapi import ( "strconv" + "strings" "github.com/gotd/td/tg" ) @@ -130,6 +131,33 @@ func entitiesFromTg(entities []tg.MessageEntityClass) []MessageEntity { case *tg.MessageEntityCustomEmoji: me.Type = EntityCustomEmoji me.CustomEmojiID = strconv.FormatInt(e.DocumentID, 10) + case *tg.MessageEntityFormattedDate: + me.Type = EntityDateTime + me.UnixTime = e.Date + + if e.Relative { + me.DateTimeFormat = "r" + } else { + var dateTimeFormat strings.Builder + + if e.DayOfWeek { + dateTimeFormat.WriteString("w") + } + + if e.ShortDate { + dateTimeFormat.WriteString("d") + } else if e.LongDate { + dateTimeFormat.WriteString("D") + } + + if e.ShortTime { + dateTimeFormat.WriteString("t") + } else if e.LongTime { + dateTimeFormat.WriteString("T") + } + + me.DateTimeFormat = dateTimeFormat.String() + } default: // Unknown or bot-irrelevant entity (e.g. unknown future types): skip. continue diff --git a/enums.go b/enums.go index 319e4ea..1599d13 100644 --- a/enums.go +++ b/enums.go @@ -73,6 +73,7 @@ const ( EntityTextLink MessageEntityType = "text_link" EntityTextMention MessageEntityType = "text_mention" EntityCustomEmoji MessageEntityType = "custom_emoji" + EntityDateTime MessageEntityType = "date_time" ) // ChatMemberStatus is a member's status in a chat. diff --git a/types_message.go b/types_message.go index da08333..5e07179 100644 --- a/types_message.go +++ b/types_message.go @@ -5,13 +5,15 @@ import "github.com/gotd/td/tg" // MessageEntity represents one special entity in a text message (e.g. a // hashtag, link, or formatted run). type MessageEntity struct { - Type MessageEntityType `json:"type"` - Offset int `json:"offset"` - Length int `json:"length"` - URL string `json:"url,omitempty"` - User *User `json:"user,omitempty"` - Language string `json:"language,omitempty"` - CustomEmojiID string `json:"custom_emoji_id,omitempty"` + Type MessageEntityType `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` + URL string `json:"url,omitempty"` + User *User `json:"user,omitempty"` + Language string `json:"language,omitempty"` + CustomEmojiID string `json:"custom_emoji_id,omitempty"` + UnixTime int `json:"unix_time,omitempty"` + DateTimeFormat string `json:"date_time_format,omitempty"` } // Contact represents a phone contact. From fea4f8160dd2882982baf96aec37e082d1722220 Mon Sep 17 00:00:00 2001 From: fluffur Date: Thu, 18 Jun 2026 21:02:49 +0500 Subject: [PATCH 2/2] test: add DateTime decode tests and update entities_test --- codec_test.go | 33 ++++++++++++++--------- entities_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/codec_test.go b/codec_test.go index ca9bb34..2ef57fc 100644 --- a/codec_test.go +++ b/codec_test.go @@ -56,19 +56,23 @@ func fullMessage() *Message { MediaGroupID: "mg", AuthorSignature: "auth", Text: "hello", - Entities: []MessageEntity{{Type: EntityBold, Offset: 0, Length: 5}, {Type: EntityTextMention, Offset: 1, Length: 2, User: user, URL: "u", Language: "go", CustomEmojiID: "e"}}, - Caption: "cap", - CaptionEntities: []MessageEntity{{Type: EntityItalic, Offset: 0, Length: 3}}, - Animation: &Animation{FileID: "a", FileUniqueID: "au", Width: 1, Height: 2, Duration: 3, Thumbnail: thumb, FileName: "a.gif", MIMEType: "image/gif", FileSize: 4}, - Audio: &Audio{FileID: "b", FileUniqueID: "bu", Duration: 10, Performer: "p", Title: "t", FileName: "b.mp3", MIMEType: "audio/mpeg", FileSize: 5, Thumbnail: thumb}, - Document: &Document{FileID: "c", FileUniqueID: "cu", Thumbnail: thumb, FileName: "c.pdf", MIMEType: "application/pdf", FileSize: 6}, - Photo: []PhotoSize{{FileID: "p1", FileUniqueID: "p1u", Width: 100, Height: 200, FileSize: 7}}, - Sticker: &Sticker{FileID: "s", FileUniqueID: "su", Type: StickerRegular, Width: 512, Height: 512, IsAnimated: true, IsVideo: true, Thumbnail: thumb, Emoji: "🙂", SetName: "set", FileSize: 8}, - Video: &Video{FileID: "v", FileUniqueID: "vu", Width: 640, Height: 480, Duration: 12, Thumbnail: thumb, FileName: "v.mp4", MIMEType: "video/mp4", FileSize: 9}, - VideoNote: &VideoNote{FileID: "vn", FileUniqueID: "vnu", Length: 240, Duration: 6, Thumbnail: thumb, FileSize: 11}, - Voice: &Voice{FileID: "vo", FileUniqueID: "vou", Duration: 4, MIMEType: "audio/ogg", FileSize: 12}, - Contact: &Contact{PhoneNumber: "+1", FirstName: "Ada", LastName: "L", UserID: 1, VCard: "vc"}, - Dice: &Dice{Emoji: DiceDart, Value: 6}, + Entities: []MessageEntity{ + {Type: EntityBold, Offset: 0, Length: 5}, + {Type: EntityTextMention, Offset: 1, Length: 2, User: user, URL: "u", Language: "go", CustomEmojiID: "e"}, + {Type: EntityDateTime, Offset: 3, Length: 4, UnixTime: 1781027109, DateTimeFormat: "wdt"}, + }, + Caption: "cap", + CaptionEntities: []MessageEntity{{Type: EntityItalic, Offset: 0, Length: 3}}, + Animation: &Animation{FileID: "a", FileUniqueID: "au", Width: 1, Height: 2, Duration: 3, Thumbnail: thumb, FileName: "a.gif", MIMEType: "image/gif", FileSize: 4}, + Audio: &Audio{FileID: "b", FileUniqueID: "bu", Duration: 10, Performer: "p", Title: "t", FileName: "b.mp3", MIMEType: "audio/mpeg", FileSize: 5, Thumbnail: thumb}, + Document: &Document{FileID: "c", FileUniqueID: "cu", Thumbnail: thumb, FileName: "c.pdf", MIMEType: "application/pdf", FileSize: 6}, + Photo: []PhotoSize{{FileID: "p1", FileUniqueID: "p1u", Width: 100, Height: 200, FileSize: 7}}, + Sticker: &Sticker{FileID: "s", FileUniqueID: "su", Type: StickerRegular, Width: 512, Height: 512, IsAnimated: true, IsVideo: true, Thumbnail: thumb, Emoji: "🙂", SetName: "set", FileSize: 8}, + Video: &Video{FileID: "v", FileUniqueID: "vu", Width: 640, Height: 480, Duration: 12, Thumbnail: thumb, FileName: "v.mp4", MIMEType: "video/mp4", FileSize: 9}, + VideoNote: &VideoNote{FileID: "vn", FileUniqueID: "vnu", Length: 240, Duration: 6, Thumbnail: thumb, FileSize: 11}, + Voice: &Voice{FileID: "vo", FileUniqueID: "vou", Duration: 4, MIMEType: "audio/ogg", FileSize: 12}, + Contact: &Contact{PhoneNumber: "+1", FirstName: "Ada", LastName: "L", UserID: 1, VCard: "vc"}, + Dice: &Dice{Emoji: DiceDart, Value: 6}, Poll: &Poll{ ID: "poll1", Question: "q?", @@ -164,6 +168,7 @@ func TestLeafEntitiesJSON(t *testing.T) { jsonRoundTrip(t, Voice{FileID: "vo", FileUniqueID: "vou", Duration: 3, MIMEType: "m", FileSize: 10}) jsonRoundTrip(t, Sticker{FileID: "s", FileUniqueID: "su", Type: StickerMask, Width: 1, Height: 2, IsAnimated: true, IsVideo: true, Thumbnail: thumb, Emoji: "x", SetName: "set", FileSize: 11}) jsonRoundTrip(t, MessageEntity{Type: EntityTextLink, Offset: 1, Length: 2, URL: "u", User: &User{ID: 1, FirstName: "A"}, Language: "go", CustomEmojiID: "e"}) + jsonRoundTrip(t, MessageEntity{Type: EntityDateTime, Offset: 0, Length: 10, UnixTime: 1781027109, DateTimeFormat: "wdt"}) jsonRoundTrip(t, Contact{PhoneNumber: "+1", FirstName: "A", LastName: "B", UserID: 1, VCard: "v"}) jsonRoundTrip(t, Dice{Emoji: DiceBasketball, Value: 5}) jsonRoundTrip(t, Location{Longitude: 1.5, Latitude: 2.25, HorizontalAccuracy: 0.5, LivePeriod: 60, Heading: 90, ProximityAlertRadius: 10}) @@ -219,6 +224,8 @@ func TestDecodeTypeMismatch(t *testing.T) { `{"photo":5}`, // array field, number value `{"reply_markup":{"inline_keyboard":7}}`, // nested array, number value `{"forward_origin":{"type":"user","date":"x"}}`, // union variant, bad field + `{"entities":[{"unix_time":"string"}]}`, // int field, string value + `{"entities":[{"date_time_format":12345}]}`, // string field, number value } for _, c := range cases { var m Message diff --git a/entities_test.go b/entities_test.go index cb96cf8..c8123f2 100644 --- a/entities_test.go +++ b/entities_test.go @@ -117,3 +117,71 @@ func TestEntitiesAllTypes(t *testing.T) { t.Fatal("expandable blockquote lost") } } + +func TestEntitiesFormattedDateFromTg(t *testing.T) { + cases := []struct { + name string + in tg.MessageEntityClass + want MessageEntity + }{ + { + name: "RelativeTime", + in: &tg.MessageEntityFormattedDate{ + Offset: 0, + Length: 5, + Date: 1781027109, + Relative: true, + }, + want: MessageEntity{ + Type: EntityDateTime, + UnixTime: 1781027109, + DateTimeFormat: "r", + }, + }, + { + name: "Absolute-DayOfWeek-ShortDate-ShortTime", + in: &tg.MessageEntityFormattedDate{ + Offset: 5, + Length: 10, + Date: 1781027109, + DayOfWeek: true, + ShortDate: true, + ShortTime: true, + }, + want: MessageEntity{ + Type: EntityDateTime, + UnixTime: 1781027109, + DateTimeFormat: "wdt", + }, + }, + { + name: "Absolute-LongDate-LongTime", + in: &tg.MessageEntityFormattedDate{ + Offset: 10, + Length: 8, + Date: 1781027109, + LongDate: true, + LongTime: true, + }, + want: MessageEntity{ + Type: EntityDateTime, + UnixTime: 1781027109, + DateTimeFormat: "DT", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := entitiesFromTg([]tg.MessageEntityClass{tc.in}) + if len(out) != 1 { + t.Fatalf("expected 1 entity, got %d", len(out)) + } + + got := out[0] + if got.Type != tc.want.Type || got.UnixTime != tc.want.UnixTime || got.DateTimeFormat != tc.want.DateTimeFormat { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +}