Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master
This commit was merged in pull request #133.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ 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"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/platform/telegram/internal/support"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Support relay: the bot forwards a user's direct messages into a per-user forum
|
// Support relay: the bot forwards a user's direct messages into a per-user forum
|
||||||
@@ -130,26 +132,20 @@ func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
|
|||||||
unlock := t.supportLocks.lock(uid)
|
unlock := t.supportLocks.lock(uid)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
rec, ok := t.support.Get(uid)
|
rec, _ := t.support.Get(uid)
|
||||||
if ok && rec.Blocked {
|
if rec.Blocked {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
topicID := 0
|
topicID, err := t.ensureTopic(ctx, m.From, rec)
|
||||||
if ok {
|
if err != nil {
|
||||||
topicID = rec.TopicID
|
t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||||
}
|
return
|
||||||
if topicID == 0 {
|
|
||||||
var err error
|
|
||||||
if topicID, err = t.openSupportTopic(ctx, m.From); err != nil {
|
|
||||||
t.log.Warn("support: open topic failed", zap.Int64("user_id", uid), zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
||||||
if err != nil && isTopicMissingErr(err) {
|
if err != nil && isTopicMissingErr(err) {
|
||||||
// The operators deleted the whole topic; reopen it and retry once.
|
// Backstop: the topic vanished between the liveness probe and the copy.
|
||||||
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
|
t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
|
||||||
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
|
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
|
||||||
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
||||||
}
|
}
|
||||||
@@ -258,6 +254,31 @@ func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, u
|
|||||||
t.answerSupportCallback(ctx, cq.ID, "Очищено")
|
t.answerSupportCallback(ctx, cq.ID, "Очищено")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureTopic returns a live forum-topic id for the user, opening a fresh topic (and
|
||||||
|
// info card) when none exists or the previous one was deleted. Telegram silently
|
||||||
|
// routes a copy aimed at a deleted topic into the chat's General topic (no error), so
|
||||||
|
// the bot cannot rely on copyMessage failing; instead it probes the info card —
|
||||||
|
// re-applying the card's reply markup is a no-op that errors only when the card (hence
|
||||||
|
// the topic) is gone — and reopens on that signal. The probe also keeps the card's
|
||||||
|
// block button in sync with the stored state.
|
||||||
|
func (t *Bot) ensureTopic(ctx context.Context, u *models.User, rec support.User) (int, error) {
|
||||||
|
if rec.TopicID == 0 || rec.HeaderMsgID == 0 {
|
||||||
|
return t.openSupportTopic(ctx, u)
|
||||||
|
}
|
||||||
|
_, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
|
||||||
|
ChatID: t.supportChatID,
|
||||||
|
MessageID: rec.HeaderMsgID,
|
||||||
|
ReplyMarkup: supportCardMarkup(u.ID, rec.Blocked),
|
||||||
|
})
|
||||||
|
if isCardMissingErr(err) {
|
||||||
|
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", u.ID), zap.Int("topic_id", rec.TopicID))
|
||||||
|
return t.openSupportTopic(ctx, u)
|
||||||
|
}
|
||||||
|
// Any other outcome (success, or a benign "message is not modified") means the
|
||||||
|
// topic is alive; reuse it.
|
||||||
|
return rec.TopicID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// openSupportTopic creates a forum topic for the user and posts its info card with
|
// openSupportTopic creates a forum topic for the user and posts its info card with
|
||||||
// the block/clear buttons, persisting both. It returns the new topic id. A failure to
|
// the block/clear buttons, persisting both. It returns the new topic id. A failure to
|
||||||
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
|
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
|
||||||
@@ -270,11 +291,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 +418,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 +444,30 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCardMissingErr reports whether err means the info-card message no longer exists
|
||||||
|
// (the operators deleted the topic), as opposed to a benign "message is not modified"
|
||||||
|
// no-op when the card is still there.
|
||||||
|
func isCardMissingErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(s, "message to edit not found") ||
|
||||||
|
strings.Contains(s, "message can't be edited") ||
|
||||||
|
strings.Contains(s, "message_id_invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
|
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type supportAPI struct {
|
|||||||
deleted [][]int
|
deleted [][]int
|
||||||
editedMsgIDs []string
|
editedMsgIDs []string
|
||||||
answers []string
|
answers []string
|
||||||
|
// editErr, when set, makes editMessageReplyMarkup return that Telegram error
|
||||||
|
// description (simulating a deleted info card / topic).
|
||||||
|
editErr string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSupportAPI(adminIDs ...int64) *supportAPI {
|
func newSupportAPI(adminIDs ...int64) *supportAPI {
|
||||||
@@ -77,6 +80,10 @@ func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.deleted = append(s.deleted, ids)
|
s.deleted = append(s.deleted, ids)
|
||||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||||
case strings.HasSuffix(path, "/editMessageReplyMarkup"):
|
case strings.HasSuffix(path, "/editMessageReplyMarkup"):
|
||||||
|
if s.editErr != "" {
|
||||||
|
fmt.Fprintf(w, `{"ok":false,"error_code":400,"description":%q}`, s.editErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
|
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
|
||||||
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
||||||
case strings.HasSuffix(path, "/answerCallbackQuery"):
|
case strings.HasSuffix(path, "/answerCallbackQuery"):
|
||||||
@@ -181,6 +188,36 @@ func TestSupportSubsequentReusesTopic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSupportTopicRecreatedWhenDeleted covers the case the operator hit in prod:
|
||||||
|
// after they delete a user's whole topic, Telegram routes a copy aimed at it into
|
||||||
|
// General with no error, so the bot must proactively detect the dead topic (the card
|
||||||
|
// probe fails) and reopen it instead of silently losing the message to General.
|
||||||
|
func TestSupportTopicRecreatedWhenDeleted(t *testing.T) {
|
||||||
|
api := newSupportAPI()
|
||||||
|
api.editErr = "message to edit not found" // the operators deleted the topic + its card
|
||||||
|
b, st := newSupportBot(t, api)
|
||||||
|
// Seed a topic that has since been deleted in Telegram.
|
||||||
|
if err := st.SetTopic(7, 999, 4999, "Ann", "", "annlee"); err != nil {
|
||||||
|
t.Fatalf("seed topic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.handleSupportUserMessage(context.Background(), userMsg(7, "still there?"))
|
||||||
|
|
||||||
|
if api.topicsOpened != 1 {
|
||||||
|
t.Fatalf("topics opened = %d, want 1 (reopened after deletion)", api.topicsOpened)
|
||||||
|
}
|
||||||
|
rec, _ := st.Get(7)
|
||||||
|
if rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
|
||||||
|
t.Errorf("store = topic %d / header %d, want the reopened 1000 / 5000", rec.TopicID, rec.HeaderMsgID)
|
||||||
|
}
|
||||||
|
if len(api.copies) != 1 || api.copies[0].threadID != "1000" {
|
||||||
|
t.Errorf("relay copies = %+v, want one into the reopened topic 1000", api.copies)
|
||||||
|
}
|
||||||
|
if _, ok := st.ByTopic(999); ok {
|
||||||
|
t.Error("stale topic 999 still indexed after reopen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSupportBlockedUserDropped(t *testing.T) {
|
func TestSupportBlockedUserDropped(t *testing.T) {
|
||||||
api := newSupportAPI()
|
api := newSupportAPI()
|
||||||
b, st := newSupportBot(t, api)
|
b, st := newSupportBot(t, api)
|
||||||
@@ -360,21 +397,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, "<b>Ann</b>") {
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user