Skip to content

Commit f723121

Browse files
ernadoclaude
andcommitted
feat(send): convert message entities to and from MTProto
entitiesToTg / entitiesFromTg map botapi.MessageEntity against tg.MessageEntityClass in both directions (offsets are UTF-16 in both APIs, so they pass through). Handles formatting runs, text links, pre/language, (expandable) blockquotes, text mentions and custom emoji. The botapi->tg switch over MessageEntityType is exhaustive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6df2e60 commit f723121

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

entities.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package botapi
2+
3+
import (
4+
"strconv"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// entitiesToTg converts Bot API message entities into MTProto entities.
10+
//
11+
// Offsets and lengths are passed through unchanged: both APIs count in UTF-16
12+
// code units. The switch over MessageEntityType is exhaustive (exhaustive lint).
13+
func entitiesToTg(entities []MessageEntity) []tg.MessageEntityClass {
14+
if len(entities) == 0 {
15+
return nil
16+
}
17+
out := make([]tg.MessageEntityClass, 0, len(entities))
18+
for _, e := range entities {
19+
off, length := e.Offset, e.Length
20+
switch e.Type {
21+
case EntityMention:
22+
out = append(out, &tg.MessageEntityMention{Offset: off, Length: length})
23+
case EntityHashtag:
24+
out = append(out, &tg.MessageEntityHashtag{Offset: off, Length: length})
25+
case EntityCashtag:
26+
out = append(out, &tg.MessageEntityCashtag{Offset: off, Length: length})
27+
case EntityBotCommand:
28+
out = append(out, &tg.MessageEntityBotCommand{Offset: off, Length: length})
29+
case EntityURL:
30+
out = append(out, &tg.MessageEntityURL{Offset: off, Length: length})
31+
case EntityEmail:
32+
out = append(out, &tg.MessageEntityEmail{Offset: off, Length: length})
33+
case EntityPhoneNumber:
34+
out = append(out, &tg.MessageEntityPhone{Offset: off, Length: length})
35+
case EntityBold:
36+
out = append(out, &tg.MessageEntityBold{Offset: off, Length: length})
37+
case EntityItalic:
38+
out = append(out, &tg.MessageEntityItalic{Offset: off, Length: length})
39+
case EntityUnderline:
40+
out = append(out, &tg.MessageEntityUnderline{Offset: off, Length: length})
41+
case EntityStrikethrough:
42+
out = append(out, &tg.MessageEntityStrike{Offset: off, Length: length})
43+
case EntitySpoiler:
44+
out = append(out, &tg.MessageEntitySpoiler{Offset: off, Length: length})
45+
case EntityBlockquote:
46+
out = append(out, &tg.MessageEntityBlockquote{Offset: off, Length: length})
47+
case EntityExpandableBlockquote:
48+
out = append(out, &tg.MessageEntityBlockquote{Offset: off, Length: length, Collapsed: true})
49+
case EntityCode:
50+
out = append(out, &tg.MessageEntityCode{Offset: off, Length: length})
51+
case EntityPre:
52+
out = append(out, &tg.MessageEntityPre{Offset: off, Length: length, Language: e.Language})
53+
case EntityTextLink:
54+
out = append(out, &tg.MessageEntityTextURL{Offset: off, Length: length, URL: e.URL})
55+
case EntityTextMention:
56+
var userID int64
57+
if e.User != nil {
58+
userID = e.User.ID
59+
}
60+
out = append(out, &tg.MessageEntityMentionName{Offset: off, Length: length, UserID: userID})
61+
case EntityCustomEmoji:
62+
id, _ := strconv.ParseInt(e.CustomEmojiID, 10, 64)
63+
out = append(out, &tg.MessageEntityCustomEmoji{Offset: off, Length: length, DocumentID: id})
64+
default:
65+
// Unknown entity type: skip rather than emit an invalid entity.
66+
}
67+
}
68+
return out
69+
}
70+
71+
// entitiesFromTg converts MTProto entities into Bot API message entities.
72+
//
73+
// Text-mention entities carry only the user id here; full user resolution is
74+
// performed by the message converter when peer data is available.
75+
func entitiesFromTg(entities []tg.MessageEntityClass) []MessageEntity {
76+
if len(entities) == 0 {
77+
return nil
78+
}
79+
out := make([]MessageEntity, 0, len(entities))
80+
for _, e := range entities {
81+
me := MessageEntity{Offset: e.GetOffset(), Length: e.GetLength()}
82+
switch e := e.(type) {
83+
case *tg.MessageEntityMention:
84+
me.Type = EntityMention
85+
case *tg.MessageEntityHashtag:
86+
me.Type = EntityHashtag
87+
case *tg.MessageEntityCashtag:
88+
me.Type = EntityCashtag
89+
case *tg.MessageEntityBotCommand:
90+
me.Type = EntityBotCommand
91+
case *tg.MessageEntityURL:
92+
me.Type = EntityURL
93+
case *tg.MessageEntityEmail:
94+
me.Type = EntityEmail
95+
case *tg.MessageEntityPhone:
96+
me.Type = EntityPhoneNumber
97+
case *tg.MessageEntityBold:
98+
me.Type = EntityBold
99+
case *tg.MessageEntityItalic:
100+
me.Type = EntityItalic
101+
case *tg.MessageEntityUnderline:
102+
me.Type = EntityUnderline
103+
case *tg.MessageEntityStrike:
104+
me.Type = EntityStrikethrough
105+
case *tg.MessageEntitySpoiler:
106+
me.Type = EntitySpoiler
107+
case *tg.MessageEntityBlockquote:
108+
if e.Collapsed {
109+
me.Type = EntityExpandableBlockquote
110+
} else {
111+
me.Type = EntityBlockquote
112+
}
113+
case *tg.MessageEntityCode:
114+
me.Type = EntityCode
115+
case *tg.MessageEntityPre:
116+
me.Type = EntityPre
117+
me.Language = e.Language
118+
case *tg.MessageEntityTextURL:
119+
me.Type = EntityTextLink
120+
me.URL = e.URL
121+
case *tg.MessageEntityMentionName:
122+
me.Type = EntityTextMention
123+
me.User = &User{ID: e.UserID}
124+
case *tg.MessageEntityCustomEmoji:
125+
me.Type = EntityCustomEmoji
126+
me.CustomEmojiID = strconv.FormatInt(e.DocumentID, 10)
127+
default:
128+
// Unknown or bot-irrelevant entity (e.g. unknown future types): skip.
129+
continue
130+
}
131+
out = append(out, me)
132+
}
133+
return out
134+
}

entities_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package botapi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
func TestEntitiesRoundTrip(t *testing.T) {
10+
in := []MessageEntity{
11+
{Type: EntityBold, Offset: 0, Length: 4},
12+
{Type: EntityTextLink, Offset: 5, Length: 3, URL: "https://example.com"},
13+
{Type: EntityPre, Offset: 9, Length: 2, Language: "go"},
14+
{Type: EntityExpandableBlockquote, Offset: 12, Length: 1},
15+
{Type: EntityTextMention, Offset: 14, Length: 2, User: &User{ID: 777}},
16+
{Type: EntityCustomEmoji, Offset: 17, Length: 1, CustomEmojiID: "12345"},
17+
}
18+
19+
tgEnts := entitiesToTg(in)
20+
if len(tgEnts) != len(in) {
21+
t.Fatalf("to-tg length: got %d want %d", len(tgEnts), len(in))
22+
}
23+
if bq, ok := tgEnts[3].(*tg.MessageEntityBlockquote); !ok || !bq.Collapsed {
24+
t.Fatalf("expandable blockquote should set Collapsed: %#v", tgEnts[3])
25+
}
26+
if mn, ok := tgEnts[4].(*tg.MessageEntityMentionName); !ok || mn.UserID != 777 {
27+
t.Fatalf("text mention user id lost: %#v", tgEnts[4])
28+
}
29+
if ce, ok := tgEnts[5].(*tg.MessageEntityCustomEmoji); !ok || ce.DocumentID != 12345 {
30+
t.Fatalf("custom emoji doc id lost: %#v", tgEnts[5])
31+
}
32+
33+
out := entitiesFromTg(tgEnts)
34+
if len(out) != len(in) {
35+
t.Fatalf("from-tg length: got %d want %d", len(out), len(in))
36+
}
37+
for i := range in {
38+
if out[i].Type != in[i].Type || out[i].Offset != in[i].Offset || out[i].Length != in[i].Length {
39+
t.Fatalf("entity %d mismatch: got %+v want %+v", i, out[i], in[i])
40+
}
41+
}
42+
if out[1].URL != "https://example.com" || out[2].Language != "go" {
43+
t.Fatalf("attribute fields lost: %+v %+v", out[1], out[2])
44+
}
45+
if out[4].User == nil || out[4].User.ID != 777 {
46+
t.Fatalf("text mention user lost: %+v", out[4])
47+
}
48+
}
49+
50+
func TestEntitiesEmpty(t *testing.T) {
51+
if entitiesToTg(nil) != nil || entitiesFromTg(nil) != nil {
52+
t.Fatal("empty input should yield nil")
53+
}
54+
}

0 commit comments

Comments
 (0)