Entities & rich text
Drop below the parse-mode layer and you find MessageEntity — the typed (offset, length, kind) triple that Telegram actually stores. Read them on incoming messages, build them by hand when parse modes are not enough.
What an entity is
A message on the wire has two parts: a plain UTF-16 string and a slice of MessageEntity objects. Each entity carries a 0-based UTF-16 code-unit offset into the string, a length in code units, a discriminator (bold? link? code?), and any kind-specific extras (the URL for a link, the user id for a mention).
That is it. There is no styled text type; the renderer in every Telegram client overlays the entities onto the plain text at display time.
Every entity kind
| Type | Meaning | Extras |
|---|---|---|
MessageEntityBold | bold | — |
MessageEntityItalic | italic | — |
MessageEntityUnderline | underline | — |
MessageEntityStrike | strikethrough | — |
MessageEntityCode | inline mono | — |
MessageEntityPre | code block | Language |
MessageEntityBlockquote | quote | Collapsed |
MessageEntitySpoiler | spoiler | — |
MessageEntityUrl | auto-detected URL | — |
MessageEntityTextUrl | hyperlink | URL |
MessageEntityMention | @username | — |
MessageEntityMentionName | inline user mention | UserID |
MessageEntityEmail | auto-detected email | — |
MessageEntityPhone | auto-detected phone | — |
MessageEntityHashtag | #tag | — |
MessageEntityCashtag | $TAG | — |
MessageEntityBotCommand | /command | — |
MessageEntityBankCard | card number | — |
MessageEntityCustomEmoji | premium emoji | DocumentID |
Reading entities from incoming messages
client.On("message:*", func(m *telegram.NewMessage) error {
for _, e := range m.Message.Entities {
switch v := e.(type) {
case *telegram.MessageEntityBold:
fmt.Printf("bold %q\n", m.Text()[v.Offset:v.Offset+v.Length])
case *telegram.MessageEntityTextUrl:
fmt.Printf("link to %s\n", v.URL)
case *telegram.MessageEntityMention:
fmt.Printf("mention %q\n", m.Text()[v.Offset:v.Offset+v.Length])
}
}
return nil
})client.On("message:*", func(m *telegram.NewMessage) error {
for _, e := range m.Message.Entities {
switch v := e.(type) {
case *telegram.MessageEntityBold:
fmt.Printf("bold %q\n", m.Text()[v.Offset:v.Offset+v.Length])
case *telegram.MessageEntityTextUrl:
fmt.Printf("link to %s\n", v.URL)
case *telegram.MessageEntityMention:
fmt.Printf("mention %q\n", m.Text()[v.Offset:v.Offset+v.Length])
}
}
return nil
})Each entity has an Offset and Lengthin UTF-16 code units — not bytes, not runes. To slice the corresponding substring portably, see the next section.
Writing entities by hand
Pass an Entities slice on SendOptions to bypass parse modes entirely. Useful when your styling comes from a structured source (a database, an LLM function call, a templating engine) and you do not want to round-trip through HTML.
text := "Click here for docs"
ents := []telegram.MessageEntity{
&telegram.MessageEntityTextUrl{
Offset: 6,
Length: 4,
URL: "https://gogram.dev",
},
}
client.SendMessage(peerID, text, &telegram.SendOptions{Entities: ents})text := "Click here for docs"
ents := []telegram.MessageEntity{
&telegram.MessageEntityTextUrl{
Offset: 6,
Length: 4,
URL: "https://gogram.dev",
},
}
client.SendMessage(peerID, text, &telegram.SendOptions{Entities: ents})The UTF-16 trap
Entity offsets are measured in UTF-16 code units because that is what MTProto uses internally. ASCII characters count as 1; most BMP characters count as 1; emoji and other characters above U+FFFF count as 2 (a surrogate pair).
If you slice an incoming Go string with the raw entity offsets you will get gibberish for anything containing emoji. gogram exposes helpers that translate UTF-16 offsets to Go byte offsets:
text := m.Text() start, end := telegram.UTF16ToGo(text, ent.Offset, ent.Length) fmt.Println(text[start:end])
text := m.Text()
start, end := telegram.UTF16ToGo(text, ent.Offset, ent.Length)
fmt.Println(text[start:end])