diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d11622f..38b92fe 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -267,6 +267,7 @@ jobs: TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }} + TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }} TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }} # The promo button reuses the UI's Mini App link variable. TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 72e5596..e4bcfac 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -179,6 +179,7 @@ jobs: TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} + TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }} TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }} TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} steps: @@ -200,6 +201,7 @@ jobs: export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID' export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID' + export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID' export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN' export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME' export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK' diff --git a/deploy/.env.example b/deploy/.env.example index 027b307..2d8f21d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -47,6 +47,7 @@ AWG_CONF= # required; AmneziaWG sidecar config (the TELEGRAM_BOT_TOKEN= # required TELEGRAM_GAME_CHANNEL_ID= TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating +TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the support relay (topic per user); empty disables it TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set diff --git a/deploy/docker-compose.bot.yml b/deploy/docker-compose.bot.yml index b4c0975..72563da 100644 --- a/deploy/docker-compose.bot.yml +++ b/deploy/docker-compose.bot.yml @@ -23,6 +23,11 @@ services: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + # Support relay: a private forum supergroup the bot relays direct user messages + # into (one topic per user). 0/unset disables it; the bot needs admin there with + # the manage-topics and delete-messages rights. + TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} + TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} @@ -46,8 +51,13 @@ services: GOMAXPROCS: "1" volumes: - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro + # Support relay state (topic mapping, block list); survives redeploys. + - bot-state:/data deploy: resources: limits: cpus: "1.0" memory: 256M + +volumes: + bot-state: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 369aa18..b0b8692 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -292,6 +292,11 @@ services: # only restricts (mutes the ineligible) — and the bot must be an admin there with the # "Ban users" right; chat_member updates are delivered only to a chat admin. TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + # The private forum supergroup the bot relays direct user messages into (one topic + # per user) and reads operator replies from. Empty disables the support relay; when + # set the bot must be an admin there with the manage-topics and delete-messages rights. + TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} + TELEGRAM_SUPPORT_STATE_DIR: /data # The optional standalone promo bot (its own token) answering /start with a button # into the main bot's app. Empty disables it; when set it needs the main bot's # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK). @@ -325,6 +330,8 @@ services: GOMAXPROCS: "1" volumes: - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro + # Support relay state (topic mapping, block list); survives redeploys. + - bot-state:/data deploy: resources: limits: @@ -496,3 +503,4 @@ volumes: prometheus-data: tempo-data: grafana-data: + bot-state: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 99d0faf..03198eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1200,3 +1200,25 @@ blocks **only** feedback submission (unlike a suspension, the whole-account bloc granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the `/users` console card. Roles are validated against a known set in Go, so adding one needs no migration. + +**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct +support channel for users who message it on Telegram. Any message other than `/start` is relayed +into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a +dedicated **forum topic** whose first message is an info card (name, @username, language, premium, +id, profile deep-link) carrying a Block/Unblock toggle and a Clear button; every message is then +copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over). +Any **administrator** of the support chat who writes in a user's topic has their message copied +back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the +user's incoming messages (the topic stays); Clear deletes the relayed messages, keeping the info +card; a topic the operators delete is reopened on the next message. State (user→topic map, block +list, relayed message ids) is a small JSON file on a writable volume — the bot host has no database +and cannot reach Postgres. The relay is **bot-local**: it touches neither the backend, the gateway, +nor `feedback_messages`. The bot must be an administrator in the forum group with the manage-topics +and delete-messages rights. + +> **Decision (2026-06-23) — bot-local support relay over forum topics.** The direct Telegram +> support channel lives entirely in the bot (no backend, no bot-link command), because the bot host +> has no database. One forum **topic per user** (not a reply-to-header thread) gives native per-user +> separation and survives message deletion; the topic id, not a fragile header message, anchors the +> mapping. Operators are the support chat's administrators (no separate owner id); messages relay +> both ways with `copyMessage`. State persists as JSON on a dedicated volume. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 11e580e..12d1fa8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -272,6 +272,15 @@ marked read once the screen shows it and disappears a week later. A badge on the unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has 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 +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 +Feedback above — it serves people who write to the bot directly rather than through the app. + ### History & statistics Finished games are archived in a dictionary-independent form and exportable to GCG; the export is offered **only once a game is finished**, and never for an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 3fc7c05..8b6e7fb 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -278,6 +278,15 @@ UTC; при создании аккаунта она подставляется ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. +### Чат поддержки в Telegram +С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту, +кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов +и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор +отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это +тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует +его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше — +канал для тех, кто пишет боту напрямую, а не через приложение. + ### История и статистика Завершённые партии архивируются в независимом от словаря виде и экспортируются в GCG; экспорт доступен **только после завершения партии** и никогда — для diff --git a/platform/telegram/Dockerfile b/platform/telegram/Dockerfile index 32e4dd4..a251a02 100644 --- a/platform/telegram/Dockerfile +++ b/platform/telegram/Dockerfile @@ -24,6 +24,11 @@ ARG VERSION=dev RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/validator ./platform/telegram/cmd/validator RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/bot ./platform/telegram/cmd/bot +# The bot's support relay writes its JSON state under /data. Stage an empty directory +# owned by the distroless nonroot UID so a fresh named volume mounted there inherits +# writable ownership (Docker copies the image dir's mode/owner into an empty volume). +RUN mkdir -p /out/data + # --- validator (home) -------------------------------------------------------- FROM gcr.io/distroless/static-debian12:nonroot AS validator COPY --from=build /out/validator /usr/local/bin/validator @@ -32,4 +37,5 @@ ENTRYPOINT ["/usr/local/bin/validator"] # --- bot (remote) ------------------------------------------------------------ FROM gcr.io/distroless/static-debian12:nonroot AS bot COPY --from=build /out/bot /usr/local/bin/bot +COPY --from=build --chown=65532:65532 /out/data /data ENTRYPOINT ["/usr/local/bin/bot"] diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 82690fa..f05f3ea 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -63,6 +63,19 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC `getChatMember`, since bots cannot list members). The bot must be an **administrator** there with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to `chat_member` updates, which Telegram delivers only to a chat admin. +- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum + supergroup**, the bot runs a direct support channel for users who message it. A user's first + non-`/start` message opens a dedicated **forum topic** whose first message is an info card (name, + @username, language, premium, id, `tg://user` profile link) carrying a **Block/Unblock** toggle + and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content + — text, media, voice, files — carries over). Any **administrator** of the support chat who writes + in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored + (the loop guard). **Block** drops a user's incoming messages (the topic stays); **Clear** deletes + the relayed messages, keeping the info card; a topic the operators delete is reopened on the next + 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. - **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 @@ -132,6 +145,8 @@ Bot (`cmd/bot`): | `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert | | `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` | | `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating | +| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay | +| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) | | `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it | | `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs | | `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs | diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index 85afd32..0ee1905 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -10,6 +10,7 @@ import ( "context" "log" "os/signal" + "path/filepath" "sync" "syscall" "time" @@ -23,6 +24,7 @@ import ( "scrabble/platform/telegram/internal/botlink" "scrabble/platform/telegram/internal/config" "scrabble/platform/telegram/internal/promobot" + "scrabble/platform/telegram/internal/support" ) // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. @@ -65,6 +67,19 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { logger.Warn("telemetry: start runtime metrics", zap.Error(err)) } + // The support relay keeps its per-user state in a JSON file on a writable volume + // (the bot host has no database). A corrupt file is logged and the relay starts + // empty rather than crash-looping the bot. + var supportStore *support.Store + if cfg.SupportChatID != 0 { + statePath := filepath.Join(cfg.SupportStateDir, "support.json") + supportStore, err = support.Open(statePath) + if err != nil { + logger.Warn("support: state load failed, starting empty", zap.String("path", statePath), zap.Error(err)) + supportStore = support.New(statePath) + } + } + b, err := bot.New(bot.Config{ Token: cfg.Token, APIBaseURL: cfg.APIBaseURL, @@ -73,6 +88,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { SendRatePerSecond: cfg.SendRatePerSecond, ChatID: cfg.ChatID, GameChannelID: cfg.GameChannelID, + SupportChatID: cfg.SupportChatID, + SupportStore: supportStore, }, logger) if err != nil { return err @@ -128,6 +145,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { zap.Bool("owns_updates", cfg.OwnsUpdates), zap.Bool("test_env", cfg.TestEnv), zap.Bool("chat_gating", cfg.ChatID != 0), + zap.Bool("support_relay", cfg.SupportChatID != 0), zap.Bool("promo_bot", promo != nil)) var wg sync.WaitGroup diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index eba0d44..f2cc25e 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -15,6 +15,8 @@ import ( "github.com/go-telegram/bot/models" "go.uber.org/zap" "golang.org/x/time/rate" + + "scrabble/platform/telegram/internal/support" ) // Config configures the bot wrapper. @@ -39,6 +41,14 @@ type Config struct { // GameChannelID is the game channel whose public @username the /start welcome links // to (resolved from this id via getChat at startup); 0 omits that follow link. GameChannelID int64 + // SupportChatID is the private forum supergroup the bot relays direct user messages + // into (one topic per user) and reads operator replies from; 0 disables the support + // relay. The bot must be an administrator there with the manage-topics and + // delete-messages rights. + SupportChatID int64 + // SupportStore persists the support relay's state (topic mapping, block list, + // relayed message ids); required when SupportChatID is set, ignored otherwise. + SupportStore *support.Store } // EligibilityResolver answers whether the Telegram user identified by externalID @@ -66,11 +76,21 @@ type Bot struct { channelUsername string chatUsername string // botID is the bot's own Telegram user id (resolved at startup); it skips the - // chat_member updates the bot's own restrict actions generate — the grant loop guard. + // chat_member updates the bot's own restrict actions generate — the grant loop guard — + // and the bot's own relayed copies in the support chat. botID int64 // eligibility resolves a joining user's chat write eligibility; nil leaves a // joiner muted (fail-closed) until it is wired. eligibility EligibilityResolver + // supportChatID is the support relay's forum supergroup (0 disables the relay). + supportChatID int64 + // support persists the support relay's per-user state; nil when the relay is off. + support *support.Store + // supportLocks serialises per-user topic creation so a burst of a new user's + // messages opens exactly one topic. + supportLocks *keyedMutex + // admins caches the support chat's administrator ids (who may reply and act). + admins *adminCache } // New builds the bot wrapper, registering the /start handler and a default handler @@ -80,7 +100,14 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { if log == nil { log = zap.NewNop() } - t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID} + t := &Bot{ + miniAppURL: cfg.MiniAppURL, + log: log, + chatID: cfg.ChatID, + channelID: cfg.GameChannelID, + supportChatID: cfg.SupportChatID, + support: cfg.SupportStore, + } if cfg.SendRatePerSecond > 0 { t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) } @@ -89,15 +116,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { tgbot.WithDefaultHandler(t.handleUpdate), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), } + if t.supportEnabled() { + t.supportLocks = newKeyedMutex() + t.admins = &adminCache{} + // The info-card buttons are callback_query updates; route them to the support + // callback handler by their "sup:" data prefix. + opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback)) + } + // Allowed updates default to "all except chat_member". Specify an explicit set only + // when we need chat_member (moderated chat) — and then re-add callback_query (which + // the explicit set would otherwise drop) when the support relay needs it. if cfg.ChatID != 0 { - // chat_member updates are off by default; subscribe explicitly (alongside - // messages) so the bot sees joins in the moderated chat. The bot must also be an - // administrator there for Telegram to deliver them. - opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{ + allowed := tgbot.AllowedUpdates{ models.AllowedUpdateMessage, models.AllowedUpdateMyChatMember, models.AllowedUpdateChatMember, - })) + } + if t.supportEnabled() { + allowed = append(allowed, models.AllowedUpdateCallbackQuery) + } + opts = append(opts, tgbot.WithAllowedUpdates(allowed)) } if cfg.TestEnv { // Route to the Bot API test environment (.../bot/test/METHOD). @@ -134,6 +172,9 @@ func (t *Bot) Run(ctx context.Context) { if t.chatID != 0 { t.logChatAdminStatus(ctx) } + if t.supportEnabled() { + t.initSupport(ctx) + } t.resolveWelcomeHandles(ctx) t.api.Start(ctx) } @@ -305,6 +346,19 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U t.handleChatMember(ctx, update.ChatMember) return } + // Support relay (when enabled): a non-/start message — /start has its own handler — + // is either an operator's reply in the support chat or a user's direct message to + // relay. Everything else falls through to the Mini App launch reply. + if t.supportEnabled() && update.Message != nil { + switch { + case update.Message.Chat.ID == t.supportChatID: + t.handleSupportGroupMessage(ctx, update.Message) + return + case update.Message.Chat.Type == models.ChatTypePrivate: + t.handleSupportUserMessage(ctx, update.Message) + return + } + } t.handleStart(ctx, api, update) } diff --git a/platform/telegram/internal/bot/support.go b/platform/telegram/internal/bot/support.go new file mode 100644 index 0000000..57d0397 --- /dev/null +++ b/platform/telegram/internal/bot/support.go @@ -0,0 +1,438 @@ +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::"). + 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::" 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, "%s\n", name) + fmt.Fprintf(&b, "Username: %s\n", username) + fmt.Fprintf(&b, "ID: %d\n", u.ID) + fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium) + fmt.Fprintf(&b, `Открыть профиль`, 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") +} diff --git a/platform/telegram/internal/bot/support_test.go b/platform/telegram/internal/bot/support_test.go new file mode 100644 index 0000000..62fb8cd --- /dev/null +++ b/platform/telegram/internal/bot/support_test.go @@ -0,0 +1,404 @@ +package bot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/go-telegram/bot/models" + "go.uber.org/zap" + + "scrabble/platform/telegram/internal/support" +) + +const supportChatID = -1001000000000 + +// copyRec records one copyMessage call. +type copyRec struct { + chatID, fromChatID, threadID, messageID string +} + +// supportAPI is a fake Bot API for the support-relay handlers: it answers the +// methods they call with incrementing ids and records the requests for assertions. +type supportAPI struct { + mu sync.Mutex + adminIDs []int64 + nextThread int + nextMsg int + topicsOpened int + copies []copyRec + headerThread string // message_thread_id of the last header sendMessage + deleted [][]int + editedMsgIDs []string + answers []string +} + +func newSupportAPI(adminIDs ...int64) *supportAPI { + return &supportAPI{adminIDs: adminIDs, nextThread: 1000, nextMsg: 5000} +} + +func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/getMe"): + io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"bot","username":"b"}}`) + case strings.HasSuffix(path, "/createForumTopic"): + s.topicsOpened++ + tid := s.nextThread + s.nextThread++ + fmt.Fprintf(w, `{"ok":true,"result":{"message_thread_id":%d,"name":%q}}`, tid, r.FormValue("name")) + case strings.HasSuffix(path, "/sendMessage"): + s.headerThread = r.FormValue("message_thread_id") + mid := s.nextMsg + s.nextMsg++ + fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid) + case strings.HasSuffix(path, "/copyMessage"): + s.copies = append(s.copies, copyRec{ + chatID: r.FormValue("chat_id"), + fromChatID: r.FormValue("from_chat_id"), + threadID: r.FormValue("message_thread_id"), + messageID: r.FormValue("message_id"), + }) + mid := s.nextMsg + s.nextMsg++ + fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid) + case strings.HasSuffix(path, "/deleteMessages"): + var ids []int + _ = json.Unmarshal([]byte(r.FormValue("message_ids")), &ids) + s.deleted = append(s.deleted, ids) + io.WriteString(w, `{"ok":true,"result":true}`) + case strings.HasSuffix(path, "/editMessageReplyMarkup"): + s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id")) + io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) + case strings.HasSuffix(path, "/answerCallbackQuery"): + s.answers = append(s.answers, r.FormValue("text")) + io.WriteString(w, `{"ok":true,"result":true}`) + case strings.HasSuffix(path, "/getChatAdministrators"): + var b strings.Builder + b.WriteString(`{"ok":true,"result":[`) + for i, id := range s.adminIDs { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"status":"administrator","user":{"id":%d,"is_bot":false}}`, id) + } + b.WriteString(`]}`) + io.WriteString(w, b.String()) + default: + io.WriteString(w, `{"ok":true,"result":true}`) + } +} + +func (s *supportAPI) copyCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.copies) +} + +// newSupportBot builds a support-enabled bot against the fake API and returns it with +// its backing store. +func newSupportBot(t *testing.T, api http.Handler) (*Bot, *support.Store) { + t.Helper() + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + st := support.New(filepath.Join(t.TempDir(), "support.json")) + b, err := New(Config{ + Token: "123:ABC", + APIBaseURL: srv.URL, + MiniAppURL: "https://example.com/telegram/", + SupportChatID: supportChatID, + SupportStore: st, + }, zap.NewNop()) + if err != nil { + t.Fatalf("new support bot: %v", err) + } + return b, st +} + +// userMsg builds a private direct message from the given user. +func userMsg(userID int64, text string) *models.Message { + return &models.Message{ + ID: int(userID)*10 + 1, + Chat: models.Chat{ID: userID, Type: models.ChatTypePrivate}, + From: &models.User{ID: userID, FirstName: "Ann", Username: "annlee", LanguageCode: "ru"}, + Text: text, + } +} + +func TestSupportFirstContactOpensTopicAndRelays(t *testing.T) { + api := newSupportAPI() + b, st := newSupportBot(t, api) + + b.handleSupportUserMessage(context.Background(), userMsg(7, "hello")) + + if api.topicsOpened != 1 { + t.Fatalf("topics opened = %d, want 1", api.topicsOpened) + } + rec, ok := st.Get(7) + if !ok || rec.TopicID != 1000 || rec.HeaderMsgID != 5000 { + t.Fatalf("store = %+v ok=%v, want topic 1000 / header 5000", rec, ok) + } + if api.headerThread != "1000" { + t.Errorf("header posted to thread %q, want 1000", api.headerThread) + } + if len(api.copies) != 1 { + t.Fatalf("copies = %d, want 1", len(api.copies)) + } + c := api.copies[0] + if c.threadID != "1000" || c.fromChatID != "7" || c.chatID != fmt.Sprint(supportChatID) { + t.Errorf("copy = %+v, want thread 1000 from 7 into the support chat", c) + } + if len(rec.RelayedMsgIDs) != 1 { + t.Errorf("relayed ids = %v, want 1 tracked", rec.RelayedMsgIDs) + } +} + +func TestSupportSubsequentReusesTopic(t *testing.T) { + api := newSupportAPI() + b, st := newSupportBot(t, api) + + b.handleSupportUserMessage(context.Background(), userMsg(7, "first")) + b.handleSupportUserMessage(context.Background(), userMsg(7, "second")) + + if api.topicsOpened != 1 { + t.Errorf("topics opened = %d, want 1 (topic reused)", api.topicsOpened) + } + if len(api.copies) != 2 { + t.Errorf("copies = %d, want 2", len(api.copies)) + } + rec, _ := st.Get(7) + if len(rec.RelayedMsgIDs) != 2 { + t.Errorf("relayed ids = %v, want 2", rec.RelayedMsgIDs) + } +} + +func TestSupportBlockedUserDropped(t *testing.T) { + api := newSupportAPI() + b, st := newSupportBot(t, api) + if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { + t.Fatalf("seed topic: %v", err) + } + if err := st.SetBlocked(7, true); err != nil { + t.Fatalf("block: %v", err) + } + + b.handleSupportUserMessage(context.Background(), userMsg(7, "hello?")) + + if api.copyCount() != 0 { + t.Errorf("a blocked user's message was relayed (%d copies)", api.copyCount()) + } + if api.topicsOpened != 0 { + t.Errorf("a blocked user opened a topic (%d)", api.topicsOpened) + } +} + +// supportGroupMsg builds an operator message inside a topic of the support chat. +func supportGroupMsg(fromID int64, threadID int, isBot bool) *models.Message { + return &models.Message{ + ID: 700 + threadID, + Chat: models.Chat{ID: supportChatID, Type: models.ChatTypeSupergroup}, + From: &models.User{ID: fromID, IsBot: isBot}, + MessageThreadID: threadID, + Text: "operator reply", + } +} + +func TestSupportOperatorReplyRelayed(t *testing.T) { + api := newSupportAPI(50) // user 50 is an admin + b, st := newSupportBot(t, api) + if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { + t.Fatalf("seed topic: %v", err) + } + + b.handleSupportGroupMessage(context.Background(), supportGroupMsg(50, 1000, false)) + + if len(api.copies) != 1 { + t.Fatalf("copies = %d, want 1 (reply relayed to user)", len(api.copies)) + } + if api.copies[0].chatID != "7" || api.copies[0].fromChatID != fmt.Sprint(supportChatID) { + t.Errorf("copy = %+v, want from the support chat to user 7", api.copies[0]) + } + rec, _ := st.Get(7) + if len(rec.RelayedMsgIDs) != 1 { + t.Errorf("operator message not tracked for clear: %v", rec.RelayedMsgIDs) + } +} + +func TestSupportGroupMessageIgnored(t *testing.T) { + cases := []struct { + name string + msg *models.Message + admins []int64 + }{ + {"bot's own copy (loop guard)", supportGroupMsg(1, 1000, true), []int64{50}}, + {"non-admin sender", supportGroupMsg(999, 1000, false), []int64{50}}, + {"outside any topic", supportGroupMsg(50, 0, false), []int64{50}}, + {"unknown topic", supportGroupMsg(50, 4242, false), []int64{50}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + api := newSupportAPI(tc.admins...) + b, st := newSupportBot(t, api) + if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { + t.Fatalf("seed topic: %v", err) + } + b.handleSupportGroupMessage(context.Background(), tc.msg) + if api.copyCount() != 0 { + t.Errorf("message relayed (%d copies); it should be ignored", api.copyCount()) + } + }) + } +} + +// supportCallback builds a callback-query update for the given data and presser. +func supportCallback(data string, fromID int64) *models.Update { + return &models.Update{CallbackQuery: &models.CallbackQuery{ + ID: "cb1", + From: models.User{ID: fromID}, + Data: data, + }} +} + +func TestSupportCallbackBlockToggle(t *testing.T) { + api := newSupportAPI(50) + b, st := newSupportBot(t, api) + if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { + t.Fatalf("seed topic: %v", err) + } + + b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 50)) + if !st.Blocked(7) { + t.Error("user not blocked after sup:block") + } + if len(api.editedMsgIDs) != 1 || api.editedMsgIDs[0] != "5000" { + t.Errorf("edited markup msg ids = %v, want [5000]", api.editedMsgIDs) + } + + b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:unblock:7", 50)) + if st.Blocked(7) { + t.Error("user still blocked after sup:unblock") + } + if len(api.answers) != 2 || api.answers[0] != "Заблокирован" || api.answers[1] != "Разблокирован" { + t.Errorf("answers = %v, want [Заблокирован Разблокирован]", api.answers) + } +} + +func TestSupportCallbackNonAdminDenied(t *testing.T) { + api := newSupportAPI(50) + b, st := newSupportBot(t, api) + if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { + t.Fatalf("seed topic: %v", err) + } + + b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 999)) + + if st.Blocked(7) { + t.Error("a non-admin managed to block the user") + } + if len(api.answers) != 1 || api.answers[0] != "Недостаточно прав" { + t.Errorf("answers = %v, want a permission denial", api.answers) + } +} + +func TestSupportCallbackClearDeletesTracked(t *testing.T) { + api := newSupportAPI(50) + b, st := newSupportBot(t, api) + if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { + t.Fatalf("seed topic: %v", err) + } + for _, id := range []int{501, 502, 503} { + if err := st.AppendMsg(7, id); err != nil { + t.Fatalf("append: %v", err) + } + } + + b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:clear:7", 50)) + + if len(api.deleted) != 1 || len(api.deleted[0]) != 3 { + t.Fatalf("deleted batches = %v, want one batch of 3", api.deleted) + } + rec, _ := st.Get(7) + if len(rec.RelayedMsgIDs) != 0 { + t.Errorf("relayed ids after clear = %v, want empty", rec.RelayedMsgIDs) + } + if rec.HeaderMsgID != 5000 { + t.Errorf("clear disturbed the header (%d)", rec.HeaderMsgID) + } +} + +func TestParseSupportCallback(t *testing.T) { + cases := []struct { + data string + wantAction string + wantUID int64 + wantOK bool + }{ + {"sup:block:7", "block", 7, true}, + {"sup:clear:-100", "clear", -100, true}, + {"sup:unblock:42", "unblock", 42, true}, + {"block:7", "", 0, false}, + {"sup:block", "", 0, false}, + {"sup:block:notanumber", "", 0, false}, + } + for _, tc := range cases { + t.Run(tc.data, func(t *testing.T) { + action, uid, ok := parseSupportCallback(tc.data) + if action != tc.wantAction || uid != tc.wantUID || ok != tc.wantOK { + t.Errorf("parseSupportCallback(%q) = %q,%d,%v; want %q,%d,%v", + tc.data, action, uid, ok, tc.wantAction, tc.wantUID, tc.wantOK) + } + }) + } +} + +func TestSupportCardTextEscapes(t *testing.T) { + u := &models.User{ID: 7, FirstName: "Ann", Username: "ann", LanguageCode: "ru", IsPremium: true} + card := supportCardText(u) + if strings.Contains(card, "Ann") { + t.Error("user-supplied first name was not HTML-escaped in the card") + } + if !strings.Contains(card, "<b>Ann</b>") { + t.Errorf("card = %q, want the escaped name", card) + } + if !strings.Contains(card, "tg://user?id=7") { + t.Error("card missing the profile deep link") + } + if !strings.Contains(card, "Premium: да") { + t.Error("card missing the premium flag") + } +} + +func TestSupportTopicName(t *testing.T) { + if got := supportTopicName(&models.User{ID: 7, FirstName: "Ann", LastName: "Lee", Username: "annlee"}); got != "Ann Lee @annlee" { + t.Errorf("topic name = %q, want %q", got, "Ann Lee @annlee") + } + if got := supportTopicName(&models.User{ID: 7}); got != "user 7" { + t.Errorf("nameless topic = %q, want %q", got, "user 7") + } + long := strings.Repeat("x", 200) + if got := supportTopicName(&models.User{ID: 7, FirstName: long}); len([]rune(got)) != supportTopicNameMax { + t.Errorf("topic name length = %d, want %d (trimmed)", len([]rune(got)), supportTopicNameMax) + } +} + +func TestSupportDisabledFallsThrough(t *testing.T) { + // With no SupportChatID the relay is off and supportEnabled is false. + srv := httptest.NewServer(&supportAPI{nextThread: 1000, nextMsg: 5000}) + t.Cleanup(srv.Close) + b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/"}, zap.NewNop()) + if err != nil { + t.Fatalf("new bot: %v", err) + } + if b.supportEnabled() { + t.Error("supportEnabled() = true without a SupportChatID") + } +} diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go index cbda8a1..8d92ca0 100644 --- a/platform/telegram/internal/config/config.go +++ b/platform/telegram/internal/config/config.go @@ -42,6 +42,16 @@ type BotConfig struct { // (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin // there with the "Ban users" right, and "chat_member" in its allowed updates. ChatID int64 + // SupportChatID is the chat id of the private forum supergroup the bot relays + // direct user messages into — one forum topic per user — and reads operator + // replies from (TELEGRAM_SUPPORT_CHAT_ID, optional; 0 disables the support relay). + // The bot must be an administrator there with the manage-topics and + // delete-messages rights. Distinct from ChatID (the moderated discussion chat). + SupportChatID int64 + // SupportStateDir is the directory holding the support relay's JSON state file + // (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the + // container user (UID 65532) and backed by a persistent volume. + SupportStateDir string // PromoBotToken is the API token of the optional standalone promo bot run in this // container — a second bot whose only job is to answer /start with a button that // opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables @@ -135,6 +145,7 @@ func LoadBot() (BotConfig, error) { PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"), BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"), BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), + SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), BotLink: BotLinkClientConfig{ GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"), @@ -152,6 +163,9 @@ func LoadBot() (BotConfig, error) { if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil { return BotConfig{}, err } + if cfg.SupportChatID, err = envInt64("TELEGRAM_SUPPORT_CHAT_ID", 0); err != nil { + return BotConfig{}, err + } if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil { return BotConfig{}, err } diff --git a/platform/telegram/internal/config/config_test.go b/platform/telegram/internal/config/config_test.go index 2aa5483..d90c706 100644 --- a/platform/telegram/internal/config/config_test.go +++ b/platform/telegram/internal/config/config_test.go @@ -151,6 +151,48 @@ func TestLoadBotChatAndPromo(t *testing.T) { }) } +// TestLoadBotSupportRelay verifies the support-relay chat id parses, the state +// directory defaults to /data, and the relay is disabled (chat id 0) by default. +func TestLoadBotSupportRelay(t *testing.T) { + t.Run("parsed", func(t *testing.T) { + setBotRequired(t) + t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "-1001234567890") + t.Setenv("TELEGRAM_SUPPORT_STATE_DIR", "/srv/state") + c, err := LoadBot() + if err != nil { + t.Fatalf("LoadBot: %v", err) + } + if c.SupportChatID != -1001234567890 { + t.Errorf("SupportChatID = %d, want -1001234567890", c.SupportChatID) + } + if c.SupportStateDir != "/srv/state" { + t.Errorf("SupportStateDir = %q, want /srv/state", c.SupportStateDir) + } + }) + + t.Run("defaults", func(t *testing.T) { + setBotRequired(t) + c, err := LoadBot() + if err != nil { + t.Fatalf("LoadBot: %v", err) + } + if c.SupportChatID != 0 { + t.Errorf("SupportChatID = %d, want 0 (relay disabled)", c.SupportChatID) + } + if c.SupportStateDir != "/data" { + t.Errorf("SupportStateDir = %q, want /data", c.SupportStateDir) + } + }) + + t.Run("malformed chat id rejected", func(t *testing.T) { + setBotRequired(t) + t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "not-a-number") + if _, err := LoadBot(); err == nil { + t.Fatal("LoadBot: expected an error for a malformed support chat id, got nil") + } + }) +} + // TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set // fails validation (the validator path). func TestLoadRejectsUnsupportedExporter(t *testing.T) { diff --git a/platform/telegram/internal/support/store.go b/platform/telegram/internal/support/store.go new file mode 100644 index 0000000..9d1ada4 --- /dev/null +++ b/platform/telegram/internal/support/store.go @@ -0,0 +1,227 @@ +// Package support persists the Telegram bot's support-relay state. For each user +// who direct-messages the bot it records the dedicated forum topic the bot opened +// in the operators' support chat, whether the user is blocked, and the ids of the +// messages relayed into that topic — so an operator's "clear" can delete them while +// keeping the topic's info card. The state is a small JSON file on a writable +// volume: the bot host has no database. +// +// The store is concurrency-safe. The go-telegram/bot library dispatches each update +// in its own goroutine, so every exported method locks. Mutators update only their +// own fields (SetTopic never touches Blocked, SetBlocked never touches the relayed +// ids) so a topic recreate cannot clobber a concurrent block toggle. +package support + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// User is one user's support-relay state. +type User struct { + // UserID is the user's Telegram user id (the private chat id the bot relays to). + UserID int64 `json:"user_id"` + // TopicID is the forum topic's message_thread_id in the support chat; 0 means no + // topic has been created yet. + TopicID int `json:"topic_id"` + // HeaderMsgID is the info-card message that opens the topic and carries the + // block/clear buttons; it is never deleted by a clear. + HeaderMsgID int `json:"header_msg_id"` + // Blocked reports whether the bot drops this user's incoming messages. + Blocked bool `json:"blocked"` + // FirstName, LastName and Username snapshot the user's Telegram profile at the + // time the topic was created or last refreshed (for the topic name and info card). + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + // RelayedMsgIDs are the ids of the messages posted into the topic after the header + // (the user's relayed messages and the operators' replies); a clear deletes them. + RelayedMsgIDs []int `json:"relayed_msg_ids,omitempty"` + // CreatedAt is when the topic was first opened for this user. + CreatedAt time.Time `json:"created_at"` +} + +// Store is the concurrency-safe, JSON-backed support-relay state. The zero value is +// not usable; build one with Open or New. +type Store struct { + mu sync.Mutex + path string + users map[int64]*User + byTopic map[int]int64 // TopicID -> UserID, for resolving operator replies +} + +// file is the on-disk shape: a flat list of users. A JSON object keyed by user id +// would force the int64 keys through strings; a list keeps the ids typed. +type file struct { + Users []*User `json:"users"` +} + +// New returns an empty store that persists to path, creating the parent directory +// when missing. Use it as the fallback when Open reports a corrupt file. +func New(path string) *Store { + _ = os.MkdirAll(filepath.Dir(path), 0o755) + return &Store{path: path, users: map[int64]*User{}, byTopic: map[int]int64{}} +} + +// Open loads the store from path. A missing file yields an empty store and no +// error; an unreadable or malformed file returns an error so the caller can decide +// whether to start empty. +func Open(path string) (*Store, error) { + s := New(path) + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, fmt.Errorf("support: read state %s: %w", path, err) + } + var f file + if err := json.Unmarshal(b, &f); err != nil { + return nil, fmt.Errorf("support: parse state %s: %w", path, err) + } + for _, u := range f.Users { + if u == nil || u.UserID == 0 { + continue + } + s.users[u.UserID] = u + if u.TopicID != 0 { + s.byTopic[u.TopicID] = u.UserID + } + } + return s, nil +} + +// Get returns a detached copy of the user's state and whether it exists. +func (s *Store) Get(userID int64) (User, bool) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[userID] + if !ok { + return User{}, false + } + cp := *u + cp.RelayedMsgIDs = append([]int(nil), u.RelayedMsgIDs...) + return cp, true +} + +// ByTopic returns the user id owning the given forum topic, and whether one is known. +func (s *Store) ByTopic(topicID int) (int64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + uid, ok := s.byTopic[topicID] + return uid, ok +} + +// Blocked reports whether the user's incoming messages are dropped. +func (s *Store) Blocked(userID int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.users[userID]; ok { + return u.Blocked + } + return false +} + +// SetTopic records the forum topic and info-card message opened for the user and +// refreshes the profile snapshot, creating the record on first contact. It rebuilds +// the topic index when the topic changes (a recreate) and preserves the block state +// and the relayed-message ids. It persists the result. +func (s *Store) SetTopic(userID int64, topicID, headerMsgID int, firstName, lastName, username string) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[userID] + if !ok { + u = &User{UserID: userID, CreatedAt: time.Now().UTC()} + s.users[userID] = u + } else if u.TopicID != 0 && u.TopicID != topicID { + delete(s.byTopic, u.TopicID) + } + u.TopicID = topicID + u.HeaderMsgID = headerMsgID + u.FirstName, u.LastName, u.Username = firstName, lastName, username + if topicID != 0 { + s.byTopic[topicID] = userID + } + return s.save() +} + +// SetBlocked sets the user's blocked flag, creating a minimal record when none +// exists, and persists the result. +func (s *Store) SetBlocked(userID int64, blocked bool) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[userID] + if !ok { + u = &User{UserID: userID, CreatedAt: time.Now().UTC()} + s.users[userID] = u + } + u.Blocked = blocked + return s.save() +} + +// AppendMsg records a message posted into the user's topic (for a later clear) and +// persists the result. It is a no-op when the user has no record yet. +func (s *Store) AppendMsg(userID int64, msgID int) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[userID] + if !ok { + return nil + } + u.RelayedMsgIDs = append(u.RelayedMsgIDs, msgID) + return s.save() +} + +// ClearMsgs empties and returns the user's relayed-message ids (the info card is not +// among them) and persists the result, so the caller can delete them from the chat. +func (s *Store) ClearMsgs(userID int64) ([]int, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[userID] + if !ok || len(u.RelayedMsgIDs) == 0 { + return nil, nil + } + ids := u.RelayedMsgIDs + u.RelayedMsgIDs = nil + if err := s.save(); err != nil { + // Roll back so the ids are not lost if the write fails. + u.RelayedMsgIDs = ids + return nil, err + } + return ids, nil +} + +// save writes the state atomically (temp file in the same directory, then rename). +// The caller must hold s.mu. +func (s *Store) save() error { + f := file{Users: make([]*User, 0, len(s.users))} + for _, u := range s.users { + f.Users = append(f.Users, u) + } + sort.Slice(f.Users, func(i, j int) bool { return f.Users[i].UserID < f.Users[j].UserID }) + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return fmt.Errorf("support: marshal state: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".support-*.json") + if err != nil { + return fmt.Errorf("support: create temp state: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + return fmt.Errorf("support: write temp state: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("support: close temp state: %w", err) + } + if err := os.Rename(tmpName, s.path); err != nil { + return fmt.Errorf("support: replace state %s: %w", s.path, err) + } + return nil +} diff --git a/platform/telegram/internal/support/store_test.go b/platform/telegram/internal/support/store_test.go new file mode 100644 index 0000000..319a3e2 --- /dev/null +++ b/platform/telegram/internal/support/store_test.go @@ -0,0 +1,178 @@ +package support + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// statePath returns a path inside a fresh temp directory for a store file. +func statePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "support.json") +} + +func TestOpenMissingReturnsEmpty(t *testing.T) { + s, err := Open(statePath(t)) + if err != nil { + t.Fatalf("Open missing: %v", err) + } + if _, ok := s.Get(42); ok { + t.Error("Get on an empty store returned a record") + } +} + +func TestOpenCorruptReturnsError(t *testing.T) { + path := statePath(t) + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write corrupt: %v", err) + } + if _, err := Open(path); err == nil { + t.Fatal("Open on a corrupt file: expected an error, got nil") + } +} + +func TestSetTopicPersistsAndReloads(t *testing.T) { + path := statePath(t) + s := New(path) + if err := s.SetTopic(7, 100, 101, "Ann", "Lee", "annlee"); err != nil { + t.Fatalf("SetTopic: %v", err) + } + + // In-memory view. + got, ok := s.Get(7) + if !ok || got.TopicID != 100 || got.HeaderMsgID != 101 || got.Username != "annlee" { + t.Fatalf("Get = %+v ok=%v, want topic 100 / header 101 / annlee", got, ok) + } + if got.CreatedAt.IsZero() { + t.Error("CreatedAt not set on first contact") + } + if uid, ok := s.ByTopic(100); !ok || uid != 7 { + t.Errorf("ByTopic(100) = %d ok=%v, want 7/true", uid, ok) + } + + // Reloaded from disk. + reload, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + got, ok = reload.Get(7) + if !ok || got.TopicID != 100 || got.FirstName != "Ann" { + t.Fatalf("reloaded Get = %+v ok=%v, want topic 100 / Ann", got, ok) + } + if uid, ok := reload.ByTopic(100); !ok || uid != 7 { + t.Errorf("reloaded ByTopic(100) = %d ok=%v, want 7/true", uid, ok) + } +} + +func TestSetTopicRecreateReindexes(t *testing.T) { + s := New(statePath(t)) + if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { + t.Fatalf("SetTopic: %v", err) + } + if err := s.SetTopic(7, 200, 201, "Ann", "", ""); err != nil { + t.Fatalf("SetTopic recreate: %v", err) + } + if _, ok := s.ByTopic(100); ok { + t.Error("ByTopic(100) still resolves after recreate; the stale topic was not dropped") + } + if uid, ok := s.ByTopic(200); !ok || uid != 7 { + t.Errorf("ByTopic(200) = %d ok=%v, want 7/true", uid, ok) + } +} + +// TestBlockSurvivesTopicRecreate is the field-isolation guarantee: a topic recreate +// (SetTopic) must not clear a block set concurrently from the buttons. +func TestBlockSurvivesTopicRecreate(t *testing.T) { + s := New(statePath(t)) + if err := s.SetTopic(7, 100, 101, "Ann", "", "annlee"); err != nil { + t.Fatalf("SetTopic: %v", err) + } + if err := s.SetBlocked(7, true); err != nil { + t.Fatalf("SetBlocked: %v", err) + } + if err := s.SetTopic(7, 200, 201, "Ann", "", "annlee"); err != nil { + t.Fatalf("SetTopic recreate: %v", err) + } + if !s.Blocked(7) { + t.Error("block was cleared by a topic recreate") + } +} + +func TestAppendAndClearMsgs(t *testing.T) { + s := New(statePath(t)) + if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { + t.Fatalf("SetTopic: %v", err) + } + for _, id := range []int{500, 501, 502} { + if err := s.AppendMsg(7, id); err != nil { + t.Fatalf("AppendMsg %d: %v", id, err) + } + } + got, _ := s.Get(7) + if len(got.RelayedMsgIDs) != 3 { + t.Fatalf("RelayedMsgIDs = %v, want 3 ids", got.RelayedMsgIDs) + } + + ids, err := s.ClearMsgs(7) + if err != nil { + t.Fatalf("ClearMsgs: %v", err) + } + if len(ids) != 3 || ids[0] != 500 || ids[2] != 502 { + t.Errorf("ClearMsgs = %v, want [500 501 502]", ids) + } + got, _ = s.Get(7) + if len(got.RelayedMsgIDs) != 0 { + t.Errorf("RelayedMsgIDs after clear = %v, want empty", got.RelayedMsgIDs) + } + if got.HeaderMsgID != 101 || got.TopicID != 100 { + t.Errorf("clear disturbed the topic/header: %+v", got) + } + + // A second clear is a no-op. + if ids, err := s.ClearMsgs(7); err != nil || ids != nil { + t.Errorf("second ClearMsgs = %v, %v, want nil, nil", ids, err) + } +} + +func TestGetReturnsDetachedCopy(t *testing.T) { + s := New(statePath(t)) + if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { + t.Fatalf("SetTopic: %v", err) + } + if err := s.AppendMsg(7, 500); err != nil { + t.Fatalf("AppendMsg: %v", err) + } + got, _ := s.Get(7) + got.RelayedMsgIDs[0] = 999 // mutate the copy + again, _ := s.Get(7) + if again.RelayedMsgIDs[0] != 500 { + t.Error("Get returned a slice that aliases the store's state") + } +} + +// TestConcurrentMutationsSafe exercises the mutex under parallel writers; run with +// -race to catch data races. +func TestConcurrentMutationsSafe(t *testing.T) { + s := New(statePath(t)) + if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { + t.Fatalf("SetTopic: %v", err) + } + var wg sync.WaitGroup + for i := range 20 { + wg.Add(1) + go func(n int) { + defer wg.Done() + _ = s.AppendMsg(7, 1000+n) + _ = s.SetBlocked(7, n%2 == 0) + _, _ = s.Get(7) + _, _ = s.ByTopic(100) + }(i) + } + wg.Wait() + got, ok := s.Get(7) + if !ok || len(got.RelayedMsgIDs) != 20 { + t.Errorf("after concurrent appends: %d ids, want 20", len(got.RelayedMsgIDs)) + } +}