From e0360c98e23d8dd36e884f30d33d6cc499afe1cc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 13 Jul 2026 09:25:20 +0200 Subject: [PATCH] feat(telegram): add /support command with support-desk info reply Intercept /support in the main bot with a dedicated handler (registered like /start), replying with a fixed support-desk info message: the operators' working hours and what to include (a description and, when possible, screenshots). The reply is Russian or English by the sender's reported Telegram language, and the command is listed in the bot's command menu (localized). Being a dedicated handler, it intercepts /support before the support relay, so the command line itself is not forwarded into an operator topic while the user's following description still is. Update the telegram README and FUNCTIONAL.md (+ _ru mirror). --- docs/FUNCTIONAL.md | 8 ++- docs/FUNCTIONAL_ru.md | 8 ++- platform/telegram/README.md | 15 ++++- platform/telegram/internal/bot/bot.go | 21 ++++++- .../telegram/internal/bot/supportreply.go | 63 +++++++++++++++++++ .../internal/bot/supportreply_test.go | 51 +++++++++++++++ 6 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 platform/telegram/internal/bot/supportreply.go create mode 100644 platform/telegram/internal/bot/supportreply_test.go diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 5da59fe..79b2de1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -407,9 +407,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th barred from feedback (a role, not a full account block) sees the send control disabled. ### Telegram support chat -A user can also reach the operators straight from the Telegram bot: anything they send the bot -other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators' -private support group, grouped into a per-user thread. The user sees no automatic reply; an +A user can also reach the operators straight from the Telegram bot. The `/support` command replies +with the support desk's working hours and what to include (a description and, when possible, +screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message, +a file — is forwarded into the operators' private support group, grouped into a per-user thread. The +user sees no automatic reply to a forwarded message; an operator answers from that thread and the bot delivers the answer back as an ordinary bot message, so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then silently ignores their messages) or clear a thread's messages. This is independent of the in-app diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c39dd0c..282ed71 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -418,9 +418,11 @@ Telegram. На сенсорном устройстве в настройках обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. ### Чат поддержки в Telegram -С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту, -кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов -и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор +С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы +поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё +остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, — +пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя. +Пользователь не получает автоответа на пересланное сообщение; оператор отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше — diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 39f8299..b187f6f 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -9,8 +9,9 @@ it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/ validation gRPC API the gateway calls during Telegram auth, on the trusted internal network with no VPN. Because it needs no Telegram reachability, **game login stays up even when the bot or the bot-link is down**. -- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start` - deep-links) and `sendMessage`, the only component reaching the Telegram Bot API. It +- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start` + deep-links, the `/support` info reply) and `sendMessage`, the only component reaching + the Telegram Bot API. It holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and executes the send commands the gateway pushes, so its egress can run on a host with native Telegram access (a VPN sidecar in the test contour, a separate host in prod). @@ -49,6 +50,13 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC chat" — rather than a dangling "@"). This is otherwise **self-contained** — the bot never calls back into the game, so `/start` onboarding works even when the game is down. +- **Support command.** `/support` replies with a fixed support-desk info message — the + operators' working hours and what to include (a description and, when possible, + screenshots). Like `/start` it answers only in a private chat and is **Russian or + English** by the sender's reported language, and it is listed in the bot's command menu + (localized, Russian/English). It is a dedicated command handler, so it intercepts + `/support` before the support relay below — the command line itself is not forwarded to + operators, while the user's following description still is. - **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion group, the bot gates who may write there. The group **allows sending by default** (a human setting) and the bot only **restricts** — Telegram intersects the chat default with @@ -76,7 +84,8 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC message. State (user→topic map, block list, relayed ids) is a JSON file under `TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must be an **administrator** in the group with the **manage-topics** and **delete-messages** rights. - The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply. + The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic + reply (the `/support` command above aside). - **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also runs a **second, standalone** bot whose only job is to answer `/start` with a localized message and a button that opens the **main** bot's Mini App. The button is a **URL** to diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index 982e3f5..061340f 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -1,6 +1,7 @@ // Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it // runs the long-poll update loop — replying to /start (with an optional deep-link -// payload) and any other message with a Mini App launch button — and sends the +// payload), to /support with the support-desk info message, and to any other message +// with a Mini App launch button — and sends the // notification and admin messages the connector requests. The bot token lives only // in this process. package bot @@ -136,6 +137,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { opts := []tgbot.Option{ tgbot.WithDefaultHandler(t.handleUpdate), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), + tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport), } if t.supportEnabled() { t.supportLocks = newKeyedMutex() @@ -186,11 +188,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { // Run sets the bot commands and the Mini App menu button, then blocks on the // long-poll update loop until ctx is cancelled. func (t *Bot) Run(ctx context.Context) { + // Command menu: the default (fallback) list is English; a Russian-scoped list + // localises the labels for ru clients. A language scope replaces the whole list, so + // it repeats /start with its own Russian label. if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ - Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}}, + Commands: []models.BotCommand{ + {Command: "start", Description: "Play Scrabble"}, + {Command: "support", Description: "Contact the administration"}, + }, }); err != nil { t.log.Warn("set commands failed", zap.Error(err)) } + if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ + LanguageCode: "ru", + Commands: []models.BotCommand{ + {Command: "start", Description: "Играть в «Эрудита»"}, + {Command: "support", Description: "Связаться с администрацией"}, + }, + }); err != nil { + t.log.Warn("set ru commands failed", zap.Error(err)) + } if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{ MenuButton: models.MenuButtonWebApp{ Type: models.MenuButtonTypeWebApp, diff --git a/platform/telegram/internal/bot/supportreply.go b/platform/telegram/internal/bot/supportreply.go new file mode 100644 index 0000000..8f4270c --- /dev/null +++ b/platform/telegram/internal/bot/supportreply.go @@ -0,0 +1,63 @@ +package bot + +import ( + "context" + "strings" + + tgbot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + "go.uber.org/zap" +) + +// The /support command replies with a fixed support-desk info message — the operators' +// working hours and what to include (a description and, when possible, screenshots). It +// is a dedicated command handler (registered like /start), so it intercepts "/support" +// before the support relay: the command line itself is not forwarded into an operator +// topic, while the user's following description still is. + +// handleSupport replies to /support with the localized support-desk info message. Like +// handleStart it answers only in a private chat — in the moderated group the bot never +// chats — and it sends a plain text message with no launch button. +func (t *Bot) handleSupport(ctx context.Context, api *tgbot.Bot, update *models.Update) { + if update.Message == nil { + return + } + if update.Message.Chat.Type != models.ChatTypePrivate { + return + } + // The sender's Telegram language rides on the message itself (Message.from.language_code); + // fall back to English when it is absent, matching startText. + lang := "" + if update.Message.From != nil { + lang = update.Message.From.LanguageCode + } + if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: supportText(lang), + }); err != nil { + t.log.Warn("reply to support failed", zap.Error(err)) + } +} + +// supportText returns the localized /support reply. Russian is used when lang (the IETF +// language tag the Telegram client reports on the message's sender) starts with "ru", +// English otherwise and when it is absent — mirroring startText's language choice. +func supportText(lang string) string { + if strings.HasPrefix(strings.ToLower(lang), "ru") { + return ruSupport + } + return enSupport +} + +// ruSupport and enSupport are the Russian and English /support replies; the English one +// is the fallback for any non-Russian or missing sender language. +const ( + ruSupport = "Поддержка «Эрудита» на связи с 08:00 до 18:00 (по времени UTC). " + + "Если Вы столкнулись с проблемой, подробно опишите её и добавьте, по возможности, скриншоты. " + + "Также будем рады выслушать Ваши пожелания и предложения по функционалу игры. " + + "Ответим в самое ближайшее время." + enSupport = "“Erudite” support is available from 08:00 to 18:00 (UTC). " + + "If you have run into a problem, please describe it in detail and attach screenshots if you can. " + + "We would also be glad to hear your wishes and suggestions about the game's features. " + + "We will reply as soon as possible." +) diff --git a/platform/telegram/internal/bot/supportreply_test.go b/platform/telegram/internal/bot/supportreply_test.go new file mode 100644 index 0000000..aa6ac08 --- /dev/null +++ b/platform/telegram/internal/bot/supportreply_test.go @@ -0,0 +1,51 @@ +package bot + +import ( + "context" + "strings" + "testing" + + "github.com/go-telegram/bot/models" +) + +func TestHandleSupportRepliesPrivateOnly(t *testing.T) { + t.Run("english by default", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support", + }}) + if api.chatID != "42" { + t.Errorf("chat_id = %q, want 42", api.chatID) + } + // No reported language -> English support reply. + if !strings.Contains(api.text, "support is available") { + t.Errorf("text = %q, want the English support reply", api.text) + } + // A plain info message carries no launch button. + if api.replyMarkup != "" { + t.Errorf("reply_markup = %q, want none", api.replyMarkup) + } + }) + t.Run("russian for a ru sender", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support", + From: &models.User{ID: 7, LanguageCode: "ru"}, + }}) + if !strings.Contains(api.text, "Поддержка «Эрудита»") { + t.Errorf("text = %q, want the Russian support reply", api.text) + } + }) + t.Run("group ignored", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/support", + }}) + if api.chatID != "" { + t.Errorf("group /support got a reply (chat=%q); the bot never chats in the group", api.chatID) + } + }) +}