feat(telegram): bot support relay — per-user forum topics
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Users who DM the bot anything but /start are relayed into a private forum supergroup, one topic per user. Operators (the chat's admins) reply in the topic and the bot copies it back to the user; an info card opening each topic carries a Block/Unblock toggle and a Clear button. State is a small JSON file on a new /data volume — the bot host has no database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is unchanged until the operator sets the chat id and adds the bot as a forum admin. - internal/support: concurrency-safe JSON store (topic map, block list, relayed message ids) with field-targeted mutators and atomic save - bot/support.go: relay both ways via copyMessage, short-TTL admin cache, callback buttons, per-user topic-create lock, loop guard (skip the bot's own posts), reopen a deleted topic on the next message - config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data pre-owned by UID 65532 so a fresh volume is writable under distroless - docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
This commit is contained in:
@@ -267,6 +267,7 @@ jobs:
|
|||||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_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 }}
|
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
|
||||||
# The promo button reuses the UI's Mini App link variable.
|
# The promo button reuses the UI's Mini App link variable.
|
||||||
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ jobs:
|
|||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_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_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
||||||
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
steps:
|
steps:
|
||||||
@@ -200,6 +201,7 @@ jobs:
|
|||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_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_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ AWG_CONF= # required; AmneziaWG sidecar config (the
|
|||||||
TELEGRAM_BOT_TOKEN= # required
|
TELEGRAM_BOT_TOKEN= # required
|
||||||
TELEGRAM_GAME_CHANNEL_ID=
|
TELEGRAM_GAME_CHANNEL_ID=
|
||||||
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating
|
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_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_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
|
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ services:
|
|||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
|
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
|
||||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_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_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||||
@@ -46,8 +51,13 @@ services:
|
|||||||
GOMAXPROCS: "1"
|
GOMAXPROCS: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
||||||
|
# Support relay state (topic mapping, block list); survives redeploys.
|
||||||
|
- bot-state:/data
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "1.0"
|
cpus: "1.0"
|
||||||
memory: 256M
|
memory: 256M
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bot-state:
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ services:
|
|||||||
# only restricts (mutes the ineligible) — and the bot must be an admin there with the
|
# 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.
|
# "Ban users" right; chat_member updates are delivered only to a chat admin.
|
||||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
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
|
# 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
|
# 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).
|
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
|
||||||
@@ -325,6 +330,8 @@ services:
|
|||||||
GOMAXPROCS: "1"
|
GOMAXPROCS: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
||||||
|
# Support relay state (topic mapping, block list); survives redeploys.
|
||||||
|
- bot-state:/data
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -496,3 +503,4 @@ volumes:
|
|||||||
prometheus-data:
|
prometheus-data:
|
||||||
tempo-data:
|
tempo-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
|
bot-state:
|
||||||
|
|||||||
@@ -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
|
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
|
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
|
||||||
migration.
|
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.
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
### History & statistics
|
||||||
Finished games are archived in a dictionary-independent form and exportable to
|
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
|
GCG; the export is offered **only once a game is finished**, and never for an
|
||||||
|
|||||||
@@ -278,6 +278,15 @@ UTC; при создании аккаунта она подставляется
|
|||||||
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
||||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||||
|
|
||||||
|
### Чат поддержки в Telegram
|
||||||
|
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
|
||||||
|
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
|
||||||
|
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
|
||||||
|
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
||||||
|
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
||||||
|
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
||||||
|
канал для тех, кто пишет боту напрямую, а не через приложение.
|
||||||
|
|
||||||
### История и статистика
|
### История и статистика
|
||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
||||||
|
|||||||
@@ -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/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
|
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) --------------------------------------------------------
|
# --- validator (home) --------------------------------------------------------
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot AS validator
|
FROM gcr.io/distroless/static-debian12:nonroot AS validator
|
||||||
COPY --from=build /out/validator /usr/local/bin/validator
|
COPY --from=build /out/validator /usr/local/bin/validator
|
||||||
@@ -32,4 +37,5 @@ ENTRYPOINT ["/usr/local/bin/validator"]
|
|||||||
# --- bot (remote) ------------------------------------------------------------
|
# --- bot (remote) ------------------------------------------------------------
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot AS bot
|
FROM gcr.io/distroless/static-debian12:nonroot AS bot
|
||||||
COPY --from=build /out/bot /usr/local/bin/bot
|
COPY --from=build /out/bot /usr/local/bin/bot
|
||||||
|
COPY --from=build --chown=65532:65532 /out/data /data
|
||||||
ENTRYPOINT ["/usr/local/bin/bot"]
|
ENTRYPOINT ["/usr/local/bin/bot"]
|
||||||
|
|||||||
@@ -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
|
`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
|
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.
|
`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
|
- **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
|
||||||
@@ -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_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_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_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_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_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 |
|
| `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 |
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"scrabble/platform/telegram/internal/botlink"
|
"scrabble/platform/telegram/internal/botlink"
|
||||||
"scrabble/platform/telegram/internal/config"
|
"scrabble/platform/telegram/internal/config"
|
||||||
"scrabble/platform/telegram/internal/promobot"
|
"scrabble/platform/telegram/internal/promobot"
|
||||||
|
"scrabble/platform/telegram/internal/support"
|
||||||
)
|
)
|
||||||
|
|
||||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
// 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))
|
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{
|
b, err := bot.New(bot.Config{
|
||||||
Token: cfg.Token,
|
Token: cfg.Token,
|
||||||
APIBaseURL: cfg.APIBaseURL,
|
APIBaseURL: cfg.APIBaseURL,
|
||||||
@@ -73,6 +88,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
|||||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||||
ChatID: cfg.ChatID,
|
ChatID: cfg.ChatID,
|
||||||
GameChannelID: cfg.GameChannelID,
|
GameChannelID: cfg.GameChannelID,
|
||||||
|
SupportChatID: cfg.SupportChatID,
|
||||||
|
SupportStore: supportStore,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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("owns_updates", cfg.OwnsUpdates),
|
||||||
zap.Bool("test_env", cfg.TestEnv),
|
zap.Bool("test_env", cfg.TestEnv),
|
||||||
zap.Bool("chat_gating", cfg.ChatID != 0),
|
zap.Bool("chat_gating", cfg.ChatID != 0),
|
||||||
|
zap.Bool("support_relay", cfg.SupportChatID != 0),
|
||||||
zap.Bool("promo_bot", promo != nil))
|
zap.Bool("promo_bot", promo != nil))
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"scrabble/platform/telegram/internal/support"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config configures the bot wrapper.
|
// Config configures the bot wrapper.
|
||||||
@@ -39,6 +41,14 @@ type Config struct {
|
|||||||
// GameChannelID is the game channel whose public @username the /start welcome links
|
// 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.
|
// to (resolved from this id via getChat at startup); 0 omits that follow link.
|
||||||
GameChannelID int64
|
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
|
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||||
@@ -66,11 +76,21 @@ type Bot struct {
|
|||||||
channelUsername string
|
channelUsername string
|
||||||
chatUsername string
|
chatUsername string
|
||||||
// botID is the bot's own Telegram user id (resolved at startup); it skips the
|
// 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
|
botID int64
|
||||||
// eligibility resolves a joining user's chat write eligibility; nil leaves a
|
// eligibility resolves a joining user's chat write eligibility; nil leaves a
|
||||||
// joiner muted (fail-closed) until it is wired.
|
// joiner muted (fail-closed) until it is wired.
|
||||||
eligibility EligibilityResolver
|
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
|
// 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 {
|
if log == nil {
|
||||||
log = zap.NewNop()
|
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 {
|
if cfg.SendRatePerSecond > 0 {
|
||||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
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.WithDefaultHandler(t.handleUpdate),
|
||||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
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 {
|
if cfg.ChatID != 0 {
|
||||||
// chat_member updates are off by default; subscribe explicitly (alongside
|
allowed := tgbot.AllowedUpdates{
|
||||||
// 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{
|
|
||||||
models.AllowedUpdateMessage,
|
models.AllowedUpdateMessage,
|
||||||
models.AllowedUpdateMyChatMember,
|
models.AllowedUpdateMyChatMember,
|
||||||
models.AllowedUpdateChatMember,
|
models.AllowedUpdateChatMember,
|
||||||
}))
|
}
|
||||||
|
if t.supportEnabled() {
|
||||||
|
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||||
|
}
|
||||||
|
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||||
}
|
}
|
||||||
if cfg.TestEnv {
|
if cfg.TestEnv {
|
||||||
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
|
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
|
||||||
@@ -134,6 +172,9 @@ func (t *Bot) Run(ctx context.Context) {
|
|||||||
if t.chatID != 0 {
|
if t.chatID != 0 {
|
||||||
t.logChatAdminStatus(ctx)
|
t.logChatAdminStatus(ctx)
|
||||||
}
|
}
|
||||||
|
if t.supportEnabled() {
|
||||||
|
t.initSupport(ctx)
|
||||||
|
}
|
||||||
t.resolveWelcomeHandles(ctx)
|
t.resolveWelcomeHandles(ctx)
|
||||||
t.api.Start(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)
|
t.handleChatMember(ctx, update.ChatMember)
|
||||||
return
|
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)
|
t.handleStart(ctx, api, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:<action>:<userID>").
|
||||||
|
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:<action>:<userID>" 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, "<b>%s</b>\n", name)
|
||||||
|
fmt.Fprintf(&b, "Username: %s\n", username)
|
||||||
|
fmt.Fprintf(&b, "ID: <code>%d</code>\n", u.ID)
|
||||||
|
fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium)
|
||||||
|
fmt.Fprintf(&b, `<a href="tg://user?id=%d">Открыть профиль</a>`, 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")
|
||||||
|
}
|
||||||
@@ -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: "<b>Ann</b>", Username: "ann", LanguageCode: "ru", IsPremium: true}
|
||||||
|
card := supportCardText(u)
|
||||||
|
if strings.Contains(card, "<b>Ann</b>") {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,16 @@ type BotConfig struct {
|
|||||||
// (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin
|
// (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.
|
// there with the "Ban users" right, and "chat_member" in its allowed updates.
|
||||||
ChatID int64
|
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
|
// 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
|
// 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
|
// 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"),
|
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
||||||
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
||||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||||
|
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||||
BotLink: BotLinkClientConfig{
|
BotLink: BotLinkClientConfig{
|
||||||
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
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 {
|
if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
|
||||||
return BotConfig{}, err
|
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 {
|
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
|
||||||
return BotConfig{}, err
|
return BotConfig{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
|
||||||
// fails validation (the validator path).
|
// fails validation (the validator path).
|
||||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user