Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master
This commit is contained in:
+5
-3
@@ -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.
|
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||||
|
|
||||||
### Telegram support chat
|
### Telegram support chat
|
||||||
A user can also reach the operators straight from the Telegram bot: anything they send the bot
|
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
|
||||||
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
|
with the support desk's working hours and what to include (a description and, when possible,
|
||||||
private support group, grouped into a per-user thread. The user sees no automatic reply; an
|
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,
|
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
|
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
|
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||||||
|
|||||||
@@ -441,9 +441,11 @@ Telegram. На сенсорном устройстве в настройках
|
|||||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||||
|
|
||||||
### Чат поддержки в Telegram
|
### Чат поддержки в Telegram
|
||||||
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
|
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
|
||||||
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
|
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
|
||||||
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
|
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
|
||||||
|
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
|
||||||
|
Пользователь не получает автоответа на пересланное сообщение; оператор
|
||||||
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
||||||
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
||||||
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
||||||
|
|||||||
@@ -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
|
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
|
network with no VPN. Because it needs no Telegram reachability, **game login stays up
|
||||||
even when the bot or the bot-link is down**.
|
even when the bot or the bot-link is down**.
|
||||||
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start`
|
- **`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
|
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
|
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
|
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).
|
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**
|
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
|
— the bot never calls back into the game, so `/start` onboarding works even when the game
|
||||||
is down.
|
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
|
- **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
|
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
|
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
|
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
|
`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.
|
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
|
- **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
|
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
|
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
// 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
|
// 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
|
// notification and admin messages the connector requests. The bot token lives only
|
||||||
// in this process.
|
// in this process.
|
||||||
package bot
|
package bot
|
||||||
@@ -136,6 +137,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|||||||
opts := []tgbot.Option{
|
opts := []tgbot.Option{
|
||||||
tgbot.WithDefaultHandler(t.handleUpdate),
|
tgbot.WithDefaultHandler(t.handleUpdate),
|
||||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||||
|
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
|
||||||
}
|
}
|
||||||
if t.supportEnabled() {
|
if t.supportEnabled() {
|
||||||
t.supportLocks = newKeyedMutex()
|
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
|
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
||||||
// long-poll update loop until ctx is cancelled.
|
// long-poll update loop until ctx is cancelled.
|
||||||
func (t *Bot) Run(ctx context.Context) {
|
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{
|
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 {
|
}); err != nil {
|
||||||
t.log.Warn("set commands failed", zap.Error(err))
|
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{
|
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
||||||
MenuButton: models.MenuButtonWebApp{
|
MenuButton: models.MenuButtonWebApp{
|
||||||
Type: models.MenuButtonTypeWebApp,
|
Type: models.MenuButtonTypeWebApp,
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user