fix(telegram): support card — text_mention name, no command/dead link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

The topic info card built the profile link as `tg://user?id=`, which
clients render as dead text for a user they don't already know (the
test-vs-prod difference an operator saw), and it put the raw display
name through HTML — so a name starting with "/" was auto-detected as a
tappable bot command that fired when tapped.

Render the card with message entities instead: the display name is
covered by a `text_mention` entity (the reliable way to mention a
username-less user the bot has already seen — it messaged the bot),
which links the profile AND, because the name sits inside an entity,
suppresses the "/command" and "@mention" auto-detection on it. Drop the
HTML parse mode and the tg:// link. Entity offsets are UTF-16.
This commit is contained in:
Ilia Denisov
2026-06-23 18:33:14 +02:00
parent d86e022373
commit bb18dc362b
4 changed files with 81 additions and 34 deletions
+47 -15
View File
@@ -360,21 +360,53 @@ func TestParseSupportCallback(t *testing.T) {
}
}
func TestSupportCardTextEscapes(t *testing.T) {
u := &models.User{ID: 7, FirstName: "<b>Ann</b>", Username: "ann", LanguageCode: "ru", IsPremium: true}
card := supportCardText(u)
if strings.Contains(card, "<b>Ann</b>") {
t.Error("user-supplied first name was not HTML-escaped in the card")
}
if !strings.Contains(card, "&lt;b&gt;Ann&lt;/b&gt;") {
t.Errorf("card = %q, want the escaped name", card)
}
if !strings.Contains(card, "tg://user?id=7") {
t.Error("card missing the profile deep link")
}
if !strings.Contains(card, "Premium: да") {
t.Error("card missing the premium flag")
}
func TestSupportCard(t *testing.T) {
t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
text, ents := supportCard(u)
// The name is rendered verbatim (no HTML, no escaping) at the start of the card.
if !strings.HasPrefix(text, "/start\n") {
t.Errorf("card = %q, want it to start with the raw name", text)
}
// A text_mention entity covers exactly the name with the user id — this both
// suppresses the "/start" command auto-detection and links the profile.
if len(ents) != 1 {
t.Fatalf("entities = %d, want 1", len(ents))
}
e := ents[0]
if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") {
t.Errorf("entity = %+v, want a text_mention over the name", e)
}
if e.User == nil || e.User.ID != 7 {
t.Errorf("entity user = %+v, want id 7", e.User)
}
if strings.Contains(text, "tg://") || strings.Contains(text, "<") {
t.Errorf("card = %q, want no tg:// link and no HTML", text)
}
if !strings.Contains(text, "Premium: да") || !strings.Contains(text, "@ann") {
t.Errorf("card = %q, want premium + @username", text)
}
})
t.Run("entity length is UTF-16, not runes", func(t *testing.T) {
// "😀A" is 2 runes but 3 UTF-16 code units (the emoji is a surrogate pair).
u := &models.User{ID: 9, FirstName: "😀A"}
_, ents := supportCard(u)
if len(ents) != 1 || ents[0].Length != 3 {
t.Fatalf("entity = %+v, want length 3 UTF-16 units", ents)
}
})
t.Run("nameless user falls back to an id label", func(t *testing.T) {
u := &models.User{ID: 42}
text, ents := supportCard(u)
if !strings.HasPrefix(text, "user 42") {
t.Errorf("card = %q, want the id fallback name", text)
}
if ents[0].Length != len("user 42") {
t.Errorf("entity length = %d, want it over the fallback name", ents[0].Length)
}
})
}
func TestSupportTopicName(t *testing.T) {