From bb18dc362b259003ff42221f2db4b80b6e22af39 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 18:33:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(telegram):=20support=20card=20=E2=80=94=20t?= =?UTF-8?q?ext=5Fmention=20name,=20no=20command/dead=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ARCHITECTURE.md | 6 +- platform/telegram/README.md | 5 +- platform/telegram/internal/bot/support.go | 42 ++++++++----- .../telegram/internal/bot/support_test.go | 62 ++++++++++++++----- 4 files changed, 81 insertions(+), 34 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03198eb..5152d10 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1204,8 +1204,10 @@ migration. **Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct support channel for users who message it on Telegram. Any message other than `/start` is relayed into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a -dedicated **forum topic** whose first message is an info card (name, @username, language, premium, -id, profile deep-link) carrying a Block/Unblock toggle and a Clear button; every message is then +dedicated **forum topic** whose first message is an info card (the name is a tappable profile +mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as +a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear +button; every message is then copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over). Any **administrator** of the support chat who writes in a user's topic has their message copied back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the diff --git a/platform/telegram/README.md b/platform/telegram/README.md index f05f3ea..6f93ccd 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -65,8 +65,9 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC `chat_member` updates, which Telegram delivers only to a chat admin. - **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum supergroup**, the bot runs a direct support channel for users who message it. A user's first - non-`/start` message opens a dedicated **forum topic** whose first message is an info card (name, - @username, language, premium, id, `tg://user` profile link) carrying a **Block/Unblock** toggle + non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the + name is a tappable profile mention via a `text_mention` entity — which also stops a name starting + with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over). Any **administrator** of the support chat who writes in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored diff --git a/platform/telegram/internal/bot/support.go b/platform/telegram/internal/bot/support.go index 57d0397..a2cba9c 100644 --- a/platform/telegram/internal/bot/support.go +++ b/platform/telegram/internal/bot/support.go @@ -3,11 +3,11 @@ package bot import ( "context" "fmt" - "html" "strconv" "strings" "sync" "time" + "unicode/utf16" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" @@ -270,11 +270,12 @@ func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error) return 0, fmt.Errorf("create forum topic: %w", err) } headerID := 0 + cardText, cardEntities := supportCard(u) header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: t.supportChatID, MessageThreadID: topic.MessageThreadID, - Text: supportCardText(u), - ParseMode: models.ParseModeHTML, + Text: cardText, + Entities: cardEntities, ReplyMarkup: supportCardMarkup(u.ID, false), }) if err != nil { @@ -396,16 +397,22 @@ func supportTopicName(u *models.User) string { return name } -// supportCardText renders the info card describing the user. User-controlled fields -// are HTML-escaped because the card uses HTML parse mode for the profile link. -func supportCardText(u *models.User) string { - name := html.EscapeString(strings.TrimSpace(u.FirstName + " " + u.LastName)) +// supportCard renders the topic info card describing the user and the message +// entities for it. The display name is covered by a text_mention entity: it links to +// the user's profile (the reliable way to mention a user who has no public username — +// the bot has seen them, since they messaged it) and, because the name sits inside an +// entity, Telegram does not auto-detect a leading "/" as a bot command or a stray +// "@handle" inside the name as a mention. The remaining lines are plain text; a public +// @username, when present, is left for Telegram to auto-link. Entity offsets are in +// UTF-16 code units, as the Bot API requires; the name is at offset 0. +func supportCard(u *models.User) (string, []models.MessageEntity) { + name := strings.TrimSpace(u.FirstName + " " + u.LastName) if name == "" { - name = "—" + name = "user " + strconv.FormatInt(u.ID, 10) } username := "—" if u.Username != "" { - username = "@" + html.EscapeString(u.Username) + username = "@" + u.Username } lang := u.LanguageCode if lang == "" { @@ -416,12 +423,17 @@ func supportCardText(u *models.User) string { premium = "да" } var b strings.Builder - fmt.Fprintf(&b, "%s\n", name) - fmt.Fprintf(&b, "Username: %s\n", username) - fmt.Fprintf(&b, "ID: %d\n", u.ID) - fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium) - fmt.Fprintf(&b, `Открыть профиль`, u.ID) - return b.String() + b.WriteString(name) + fmt.Fprintf(&b, "\nUsername: %s", username) + fmt.Fprintf(&b, "\nID: %d", u.ID) + fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium) + entities := []models.MessageEntity{{ + Type: models.MessageEntityTypeTextMention, + Offset: 0, + Length: len(utf16.Encode([]rune(name))), + User: &models.User{ID: u.ID}, + }} + return b.String(), entities } // isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic diff --git a/platform/telegram/internal/bot/support_test.go b/platform/telegram/internal/bot/support_test.go index 62fb8cd..48b210b 100644 --- a/platform/telegram/internal/bot/support_test.go +++ b/platform/telegram/internal/bot/support_test.go @@ -360,21 +360,53 @@ func TestParseSupportCallback(t *testing.T) { } } -func TestSupportCardTextEscapes(t *testing.T) { - u := &models.User{ID: 7, FirstName: "Ann", Username: "ann", LanguageCode: "ru", IsPremium: true} - card := supportCardText(u) - if strings.Contains(card, "Ann") { - t.Error("user-supplied first name was not HTML-escaped in the card") - } - if !strings.Contains(card, "<b>Ann</b>") { - 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) {