fix(telegram): support relay — text_mention card + reopen deleted topic #132

Merged
developer merged 2 commits from feature/telegram-support-card-mention into development 2026-06-23 16:51:04 +00:00
4 changed files with 81 additions and 34 deletions
Showing only changes of commit bb18dc362b - Show all commits
+4 -2
View File
@@ -1204,8 +1204,10 @@ migration.
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct **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 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 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, dedicated **forum topic** whose first message is an info card (the name is a tappable profile
id, profile deep-link) carrying a Block/Unblock toggle and a Clear button; every message is then 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). 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 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 back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
+3 -2
View File
@@ -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. `chat_member` updates, which Telegram delivers only to a chat admin.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum - **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 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, non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
@username, language, premium, id, `tg://user` profile link) carrying a **Block/Unblock** toggle 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 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 — 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 in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
+27 -15
View File
@@ -3,11 +3,11 @@ package bot
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf16"
tgbot "github.com/go-telegram/bot" tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "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) return 0, fmt.Errorf("create forum topic: %w", err)
} }
headerID := 0 headerID := 0
cardText, cardEntities := supportCard(u)
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: t.supportChatID, ChatID: t.supportChatID,
MessageThreadID: topic.MessageThreadID, MessageThreadID: topic.MessageThreadID,
Text: supportCardText(u), Text: cardText,
ParseMode: models.ParseModeHTML, Entities: cardEntities,
ReplyMarkup: supportCardMarkup(u.ID, false), ReplyMarkup: supportCardMarkup(u.ID, false),
}) })
if err != nil { if err != nil {
@@ -396,16 +397,22 @@ func supportTopicName(u *models.User) string {
return name return name
} }
// supportCardText renders the info card describing the user. User-controlled fields // supportCard renders the topic info card describing the user and the message
// are HTML-escaped because the card uses HTML parse mode for the profile link. // entities for it. The display name is covered by a text_mention entity: it links to
func supportCardText(u *models.User) string { // the user's profile (the reliable way to mention a user who has no public username —
name := html.EscapeString(strings.TrimSpace(u.FirstName + " " + u.LastName)) // 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 == "" { if name == "" {
name = "—" name = "user " + strconv.FormatInt(u.ID, 10)
} }
username := "—" username := "—"
if u.Username != "" { if u.Username != "" {
username = "@" + html.EscapeString(u.Username) username = "@" + u.Username
} }
lang := u.LanguageCode lang := u.LanguageCode
if lang == "" { if lang == "" {
@@ -416,12 +423,17 @@ func supportCardText(u *models.User) string {
premium = "да" premium = "да"
} }
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "<b>%s</b>\n", name) b.WriteString(name)
fmt.Fprintf(&b, "Username: %s\n", username) fmt.Fprintf(&b, "\nUsername: %s", username)
fmt.Fprintf(&b, "ID: <code>%d</code>\n", u.ID) fmt.Fprintf(&b, "\nID: %d", u.ID)
fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium) fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium)
fmt.Fprintf(&b, `<a href="tg://user?id=%d">Открыть профиль</a>`, u.ID) entities := []models.MessageEntity{{
return b.String() 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 // isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
+47 -15
View File
@@ -360,21 +360,53 @@ func TestParseSupportCallback(t *testing.T) {
} }
} }
func TestSupportCardTextEscapes(t *testing.T) { func TestSupportCard(t *testing.T) {
u := &models.User{ID: 7, FirstName: "<b>Ann</b>", Username: "ann", LanguageCode: "ru", IsPremium: true} t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
card := supportCardText(u) u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
if strings.Contains(card, "<b>Ann</b>") { text, ents := supportCard(u)
t.Error("user-supplied first name was not HTML-escaped in the card") // The name is rendered verbatim (no HTML, no escaping) at the start of the card.
} if !strings.HasPrefix(text, "/start\n") {
if !strings.Contains(card, "&lt;b&gt;Ann&lt;/b&gt;") { t.Errorf("card = %q, want it to start with the raw name", text)
t.Errorf("card = %q, want the escaped name", card) }
} // A text_mention entity covers exactly the name with the user id — this both
if !strings.Contains(card, "tg://user?id=7") { // suppresses the "/start" command auto-detection and links the profile.
t.Error("card missing the profile deep link") if len(ents) != 1 {
} t.Fatalf("entities = %d, want 1", len(ents))
if !strings.Contains(card, "Premium: да") { }
t.Error("card missing the premium flag") 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) { func TestSupportTopicName(t *testing.T) {