diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0555dd1..5a6215f 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -430,9 +430,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 a24f538..5244050 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -441,9 +441,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) + } + }) +}