From fc1715128e9b825700669db2f53fbc79a85a5f38 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 18:45:39 +0200 Subject: [PATCH] fix(telegram): reopen a deleted support topic instead of losing it to General MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram silently routes a copyMessage aimed at a deleted forum topic into the chat's General topic with NO error, so the bot's error-based recreate never fired — the user's next message landed in General with no card. Confirmed against the prod bot logs: a relay after a topic deletion logged neither "relay to topic failed" nor "topic gone, reopening". Probe topic liveness before reusing it: re-applying the info card's reply markup is a no-op that errors only when the card (hence the topic) is gone, so the bot detects the deletion and reopens the topic + card rather than relying on the (absent) copy error. The post-copy recreate stays as a race backstop. --- platform/telegram/internal/bot/support.go | 62 ++++++++++++++----- .../telegram/internal/bot/support_test.go | 37 +++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/platform/telegram/internal/bot/support.go b/platform/telegram/internal/bot/support.go index a2cba9c..7341d03 100644 --- a/platform/telegram/internal/bot/support.go +++ b/platform/telegram/internal/bot/support.go @@ -12,6 +12,8 @@ import ( tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "go.uber.org/zap" + + "scrabble/platform/telegram/internal/support" ) // 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) defer unlock() - rec, ok := t.support.Get(uid) - if ok && rec.Blocked { + rec, _ := t.support.Get(uid) + if rec.Blocked { return } - topicID := 0 - if ok { - topicID = rec.TopicID - } - 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 - } + topicID, err := t.ensureTopic(ctx, m.From, rec) + if err != nil { + t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err)) + return } newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID) if err != nil && isTopicMissingErr(err) { - // The operators deleted the whole topic; reopen it and retry once. - t.log.Info("support: topic gone, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID)) + // Backstop: the topic vanished between the liveness probe and the copy. + 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 { 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, "Очищено") } +// 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 // 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. @@ -436,6 +457,19 @@ func supportCard(u *models.User) (string, []models.MessageEntity) { 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 // error, signalling the topic must be reopened. func isTopicMissingErr(err error) bool { diff --git a/platform/telegram/internal/bot/support_test.go b/platform/telegram/internal/bot/support_test.go index 48b210b..dc036dc 100644 --- a/platform/telegram/internal/bot/support_test.go +++ b/platform/telegram/internal/bot/support_test.go @@ -38,6 +38,9 @@ type supportAPI struct { deleted [][]int editedMsgIDs []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 { @@ -77,6 +80,10 @@ func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.deleted = append(s.deleted, ids) io.WriteString(w, `{"ok":true,"result":true}`) 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")) io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) 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) { api := newSupportAPI() b, st := newSupportBot(t, api)