release v1.5.1: support-relay card + topic-reopen fixes #133

Merged
developer merged 3 commits from development into master 2026-06-23 16:54:01 +00:00
2 changed files with 85 additions and 14 deletions
Showing only changes of commit fc1715128e - Show all commits
+48 -14
View File
@@ -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 {
@@ -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)