Files
scrabble-game/platform/telegram/internal/bot/support.go
T
Ilia Denisov 6a602aefae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
feat(telegram): bot support relay — per-user forum topics
Users who DM the bot anything but /start are relayed into a private
forum supergroup, one topic per user. Operators (the chat's admins)
reply in the topic and the bot copies it back to the user; an info card
opening each topic carries a Block/Unblock toggle and a Clear button.
State is a small JSON file on a new /data volume — the bot host has no
database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is
unchanged until the operator sets the chat id and adds the bot as a
forum admin.

- internal/support: concurrency-safe JSON store (topic map, block list,
  relayed message ids) with field-targeted mutators and atomic save
- bot/support.go: relay both ways via copyMessage, short-TTL admin
  cache, callback buttons, per-user topic-create lock, loop guard
  (skip the bot's own posts), reopen a deleted topic on the next message
- config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per
  contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data
  pre-owned by UID 65532 so a fresh volume is writable under distroless
- docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
2026-06-23 17:47:00 +02:00

439 lines
15 KiB
Go

package bot
import (
"context"
"fmt"
"html"
"strconv"
"strings"
"sync"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
// Support relay: the bot forwards a user's direct messages into a per-user forum
// topic in the operators' private support chat, and relays operator replies in that
// topic back to the user. The info card opening each topic carries a Block/Unblock
// toggle and a Clear button. State (topic mapping, block list, relayed message ids)
// lives in a small JSON file via internal/support.
const (
// supportCallbackPrefix namespaces the info-card button callbacks
// ("sup:<action>:<userID>").
supportCallbackPrefix = "sup:"
supportActionBlock = "block"
supportActionUnblock = "unblock"
supportActionClear = "clear"
// supportAdminTTL is how long the support chat's administrator set is cached.
supportAdminTTL = time.Minute
// supportTopicNameMax bounds the forum topic name (the Telegram limit is 128).
supportTopicNameMax = 128
// supportDeleteBatch is the maximum message ids per deleteMessages call (the
// Telegram limit is 100).
supportDeleteBatch = 100
)
// supportEnabled reports whether the support relay is configured.
func (t *Bot) supportEnabled() bool {
return t.supportChatID != 0 && t.support != nil
}
// initSupport prepares the support relay at startup: it resolves the bot's own user
// id (getMe) for the loop guard, unless the chat-gating self-check already did, and
// logs readiness. The loop guard also tests From.IsBot, so an unresolved id is not
// fatal.
func (t *Bot) initSupport(ctx context.Context) {
if t.botID == 0 {
if me, err := t.api.GetMe(ctx); err != nil {
t.log.Warn("support: getMe failed; relying on is_bot for the loop guard", zap.Error(err))
} else {
t.botID = me.ID
}
}
t.log.Info("support relay ready", zap.Int64("support_chat_id", t.supportChatID))
}
// keyedMutex serialises work per int64 key (here, per user id) so two concurrent
// updates from the same user — the go-telegram/bot library runs handlers in
// goroutines — cannot both open a forum topic.
type keyedMutex struct {
mu sync.Mutex
m map[int64]*sync.Mutex
}
func newKeyedMutex() *keyedMutex { return &keyedMutex{m: map[int64]*sync.Mutex{}} }
// lock acquires the per-key mutex and returns its unlock function.
func (k *keyedMutex) lock(key int64) func() {
k.mu.Lock()
mu, ok := k.m[key]
if !ok {
mu = &sync.Mutex{}
k.m[key] = mu
}
k.mu.Unlock()
mu.Lock()
return mu.Unlock
}
// adminCache caches the support chat's administrator ids for a short TTL, so the bot
// does not call getChatAdministrators on every operator action.
type adminCache struct {
mu sync.Mutex
ids map[int64]bool
fetchedAt time.Time
}
// isSupportAdmin reports whether userID administers the support chat, refreshing the
// cache when stale. On a refresh failure it falls back to the last known set
// (fail-closed when nothing is cached yet).
func (t *Bot) isSupportAdmin(ctx context.Context, userID int64) bool {
t.admins.mu.Lock()
if t.admins.ids != nil && time.Since(t.admins.fetchedAt) < supportAdminTTL {
ok := t.admins.ids[userID]
t.admins.mu.Unlock()
return ok
}
t.admins.mu.Unlock()
members, err := t.api.GetChatAdministrators(ctx, &tgbot.GetChatAdministratorsParams{ChatID: t.supportChatID})
if err != nil {
t.log.Warn("support: getChatAdministrators failed; using cached admin set", zap.Error(err))
t.admins.mu.Lock()
defer t.admins.mu.Unlock()
return t.admins.ids[userID] // a nil map yields false (fail-closed)
}
ids := make(map[int64]bool, len(members))
for _, m := range members {
if u := chatMemberUser(m); u != nil {
ids[u.ID] = true
}
}
t.admins.mu.Lock()
t.admins.ids = ids
t.admins.fetchedAt = time.Now()
t.admins.mu.Unlock()
return ids[userID]
}
// handleSupportUserMessage relays a user's direct message into their forum topic in
// the support chat, opening the topic (and its info card) on first contact. A blocked
// user's message is dropped silently, and the user receives no reply — operators
// answer from the topic.
func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
if m.From == nil || m.From.IsBot {
return
}
uid := m.From.ID
unlock := t.supportLocks.lock(uid)
defer unlock()
rec, ok := t.support.Get(uid)
if ok && 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
}
}
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))
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
}
}
if err != nil {
t.log.Warn("support: relay to topic failed", zap.Int64("user_id", uid), zap.Error(err))
return
}
if err := t.support.AppendMsg(uid, newID); err != nil {
t.log.Warn("support: persist relayed id failed", zap.Int64("user_id", uid), zap.Error(err))
}
}
// handleSupportGroupMessage relays an operator's reply in a user's topic back to that
// user. It ignores the bot's own posts (the loop guard), messages outside a topic,
// unknown topics, and non-administrators. The operator's message is tracked too, so a
// later clear removes it.
func (t *Bot) handleSupportGroupMessage(ctx context.Context, m *models.Message) {
if m.From == nil || m.From.IsBot {
return // the bot's own relayed copies (and other bots) — never loop them back
}
if t.botID != 0 && m.From.ID == t.botID {
return
}
if m.MessageThreadID == 0 {
return // the chat's general area, not a user's topic
}
uid, ok := t.support.ByTopic(m.MessageThreadID)
if !ok {
return
}
if !t.isSupportAdmin(ctx, m.From.ID) {
return // only operators reach the user
}
if err := t.support.AppendMsg(uid, m.ID); err != nil {
t.log.Warn("support: persist operator msg id failed", zap.Int64("user_id", uid), zap.Error(err))
}
if _, err := t.copyToUser(ctx, m.ID, uid); err != nil {
t.log.Warn("support: relay reply to user failed", zap.Int64("user_id", uid), zap.Error(err))
}
}
// handleSupportCallback handles the info-card buttons (block/unblock/clear). Only
// support-chat administrators may act; others get a denial. It always answers the
// callback query so the client's spinner clears.
func (t *Bot) handleSupportCallback(ctx context.Context, _ *tgbot.Bot, update *models.Update) {
cq := update.CallbackQuery
if cq == nil {
return
}
action, uid, ok := parseSupportCallback(cq.Data)
if !ok {
t.answerSupportCallback(ctx, cq.ID, "")
return
}
if !t.isSupportAdmin(ctx, cq.From.ID) {
t.answerSupportCallback(ctx, cq.ID, "Недостаточно прав")
return
}
switch action {
case supportActionBlock:
t.setSupportBlocked(ctx, cq, uid, true)
case supportActionUnblock:
t.setSupportBlocked(ctx, cq, uid, false)
case supportActionClear:
t.clearSupportTopic(ctx, cq, uid)
default:
t.answerSupportCallback(ctx, cq.ID, "")
}
}
// setSupportBlocked sets the user's block state, flips the info-card toggle to match,
// and answers the callback. Blocking does not delete the topic — it stays.
func (t *Bot) setSupportBlocked(ctx context.Context, cq *models.CallbackQuery, uid int64, blocked bool) {
if err := t.support.SetBlocked(uid, blocked); err != nil {
t.log.Warn("support: set blocked failed", zap.Int64("user_id", uid), zap.Error(err))
t.answerSupportCallback(ctx, cq.ID, "Ошибка")
return
}
if rec, ok := t.support.Get(uid); ok && rec.HeaderMsgID != 0 {
if _, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
ChatID: t.supportChatID,
MessageID: rec.HeaderMsgID,
ReplyMarkup: supportCardMarkup(uid, blocked),
}); err != nil {
t.log.Debug("support: update card markup failed", zap.Error(err))
}
}
msg := "Разблокирован"
if blocked {
msg = "Заблокирован"
}
t.answerSupportCallback(ctx, cq.ID, msg)
}
// clearSupportTopic deletes the messages relayed into the user's topic (keeping the
// info card) and answers the callback.
func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, uid int64) {
ids, err := t.support.ClearMsgs(uid)
if err != nil {
t.log.Warn("support: clear failed", zap.Int64("user_id", uid), zap.Error(err))
t.answerSupportCallback(ctx, cq.ID, "Ошибка")
return
}
t.deleteSupportMessages(ctx, ids)
t.answerSupportCallback(ctx, cq.ID, "Очищено")
}
// 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.
func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error) {
topic, err := t.api.CreateForumTopic(ctx, &tgbot.CreateForumTopicParams{
ChatID: t.supportChatID,
Name: supportTopicName(u),
})
if err != nil {
return 0, fmt.Errorf("create forum topic: %w", err)
}
headerID := 0
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: t.supportChatID,
MessageThreadID: topic.MessageThreadID,
Text: supportCardText(u),
ParseMode: models.ParseModeHTML,
ReplyMarkup: supportCardMarkup(u.ID, false),
})
if err != nil {
t.log.Warn("support: post info card failed (topic opened without it)", zap.Error(err))
} else {
headerID = header.ID
}
if err := t.support.SetTopic(u.ID, topic.MessageThreadID, headerID, u.FirstName, u.LastName, u.Username); err != nil {
t.log.Warn("support: persist topic state failed (kept in memory)", zap.Int64("user_id", u.ID), zap.Error(err))
}
return topic.MessageThreadID, nil
}
// copyToTopic copies a message into the user's forum topic and returns the new
// message id.
func (t *Bot) copyToTopic(ctx context.Context, fromChatID int64, messageID, topicID int) (int, error) {
if err := t.throttle(ctx); err != nil {
return 0, err
}
res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
ChatID: t.supportChatID,
MessageThreadID: topicID,
FromChatID: fromChatID,
MessageID: messageID,
})
if err != nil {
return 0, err
}
return res.ID, nil
}
// copyToUser copies a support-chat message to the user's private chat and returns the
// new message id.
func (t *Bot) copyToUser(ctx context.Context, messageID int, userID int64) (int, error) {
if err := t.throttle(ctx); err != nil {
return 0, err
}
res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
ChatID: userID,
FromChatID: t.supportChatID,
MessageID: messageID,
})
if err != nil {
return 0, err
}
return res.ID, nil
}
// deleteSupportMessages best-effort deletes the given support-chat messages in
// batches; individual failures (for example a message already removed) are ignored.
func (t *Bot) deleteSupportMessages(ctx context.Context, ids []int) {
for start := 0; start < len(ids); start += supportDeleteBatch {
end := min(start+supportDeleteBatch, len(ids))
if _, err := t.api.DeleteMessages(ctx, &tgbot.DeleteMessagesParams{
ChatID: t.supportChatID,
MessageIDs: ids[start:end],
}); err != nil {
t.log.Debug("support: delete batch failed", zap.Error(err))
}
}
}
// answerSupportCallback answers a callback query, logging a failure at debug (the
// only effect of a miss is a lingering client spinner).
func (t *Bot) answerSupportCallback(ctx context.Context, id, text string) {
if _, err := t.api.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{
CallbackQueryID: id,
Text: text,
}); err != nil {
t.log.Debug("support: answer callback failed", zap.Error(err))
}
}
// parseSupportCallback parses "sup:<action>:<userID>" callback data.
func parseSupportCallback(data string) (action string, userID int64, ok bool) {
rest, found := strings.CutPrefix(data, supportCallbackPrefix)
if !found {
return "", 0, false
}
action, idStr, found := strings.Cut(rest, ":")
if !found {
return "", 0, false
}
userID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return "", 0, false
}
return action, userID, true
}
// supportCardMarkup builds the info-card buttons: a Block/Unblock toggle reflecting
// the current state, and a Clear button.
func supportCardMarkup(userID int64, blocked bool) *models.InlineKeyboardMarkup {
toggleText, toggleAction := "Заблокировать", supportActionBlock
if blocked {
toggleText, toggleAction = "Разблокировать", supportActionUnblock
}
return &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{{
{Text: toggleText, CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, toggleAction, userID)},
{Text: "Очистить", CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, supportActionClear, userID)},
}},
}
}
// supportTopicName builds the forum topic title for a user: full name and @username,
// trimmed to the Telegram length limit.
func supportTopicName(u *models.User) string {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" {
name = "user " + strconv.FormatInt(u.ID, 10)
}
if u.Username != "" {
name += " @" + u.Username
}
if r := []rune(name); len(r) > supportTopicNameMax {
return string(r[:supportTopicNameMax])
}
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))
if name == "" {
name = "—"
}
username := "—"
if u.Username != "" {
username = "@" + html.EscapeString(u.Username)
}
lang := u.LanguageCode
if lang == "" {
lang = "—"
}
premium := "нет"
if u.IsPremium {
premium = "да"
}
var b strings.Builder
fmt.Fprintf(&b, "<b>%s</b>\n", name)
fmt.Fprintf(&b, "Username: %s\n", username)
fmt.Fprintf(&b, "ID: <code>%d</code>\n", u.ID)
fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium)
fmt.Fprintf(&b, `<a href="tg://user?id=%d">Открыть профиль</a>`, u.ID)
return b.String()
}
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
// error, signalling the topic must be reopened.
func isTopicMissingErr(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "thread not found") ||
strings.Contains(s, "thread_not_found") ||
strings.Contains(s, "topic deleted") ||
strings.Contains(s, "topic_deleted")
}