package bot import ( "context" "fmt" "strconv" "strings" "sync" "time" "unicode/utf16" 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::"). 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 cardText, cardEntities := supportCard(u) header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: t.supportChatID, MessageThreadID: topic.MessageThreadID, Text: cardText, Entities: cardEntities, 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::" 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 } // supportCard renders the topic info card describing the user and the message // entities for it. The display name is covered by a text_mention entity: it links to // the user's profile (the reliable way to mention a user who has no public username — // 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 == "" { name = "user " + strconv.FormatInt(u.ID, 10) } username := "—" if u.Username != "" { username = "@" + u.Username } lang := u.LanguageCode if lang == "" { lang = "—" } premium := "нет" if u.IsPremium { premium = "да" } var b strings.Builder b.WriteString(name) fmt.Fprintf(&b, "\nUsername: %s", username) fmt.Fprintf(&b, "\nID: %d", u.ID) fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium) entities := []models.MessageEntity{{ 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 // 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") }