Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3456a0b3ff | |||
| 93ccc8c449 | |||
| e0360c98e2 | |||
| a41281c495 | |||
| 11f89f6477 | |||
| 2d1fadb50c | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -353,6 +353,11 @@ jobs:
|
||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
|
||||
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
|
||||
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
@@ -112,6 +112,12 @@ jobs:
|
||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
|
||||
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
|
||||
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
|
||||
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
||||
TELEGRAM_TEST_ENV=false
|
||||
TELEGRAM_API_BASE_URL=
|
||||
|
||||
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
|
||||
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
|
||||
# variables, shared across contours; in the test contour the stamped client version is a commit hash
|
||||
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
|
||||
GATEWAY_MIN_CLIENT_VERSION=
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION=
|
||||
|
||||
# --- VK Mini App ------------------------------------------------------------
|
||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
|
||||
@@ -222,6 +222,12 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
|
||||
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
|
||||
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
|
||||
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
|
||||
# Telegram auth validates against the home validator (plaintext, internal).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
|
||||
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||
export DICT_VERSION='$DICT_VERSION'
|
||||
export APP_VERSION='$APP_VERSION'
|
||||
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
|
||||
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
|
||||
@@ -116,8 +116,10 @@ dropped). Horizontal scaling is explicit future work.
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
|
||||
signal, so those channels never enter an offline state or show a local lobby.
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
||||
local lobby, no transport kill switch and no device-local create paths.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -1393,9 +1395,11 @@ for install on Android) and powers **offline play**. Offline is **implicit** —
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
|
||||
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
|
||||
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
|
||||
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
|
||||
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
|
||||
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
|
||||
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
|
||||
hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
|
||||
+9
-4
@@ -277,7 +277,9 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
|
||||
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||||
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||||
native apps only). When it cannot reach the
|
||||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||
@@ -295,6 +297,7 @@ a short note.
|
||||
|
||||
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
|
||||
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
|
||||
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
|
||||
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||
@@ -427,9 +430,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
|
||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||
|
||||
### Telegram support chat
|
||||
A user can also reach the operators straight from the Telegram bot: anything they send the bot
|
||||
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
|
||||
private support group, grouped into a per-user thread. The user sees no automatic reply; an
|
||||
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
|
||||
with the support desk's working hours and what to include (a description and, when possible,
|
||||
screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
|
||||
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
|
||||
user sees no automatic reply to a forwarded message; an
|
||||
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
||||
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
||||
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||||
|
||||
+11
-6
@@ -283,7 +283,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
|
||||
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||
приложениям). Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
@@ -299,8 +301,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
|
||||
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
@@ -438,9 +441,11 @@ Telegram. На сенсорном устройстве в настройках
|
||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||
|
||||
### Чат поддержки в Telegram
|
||||
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
|
||||
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
|
||||
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
|
||||
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
|
||||
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
|
||||
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
|
||||
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
|
||||
Пользователь не получает автоответа на пересланное сообщение; оператор
|
||||
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
||||
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
||||
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
||||
|
||||
+8
-3
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION \
|
||||
VITE_ADS_STUB=$VITE_ADS_STUB
|
||||
|
||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
# Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
|
||||
# --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
|
||||
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
|
||||
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
|
||||
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
|
||||
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
|
||||
# installs (where android:assets does need sharp built).
|
||||
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/
|
||||
validation gRPC API the gateway calls during Telegram auth, on the trusted internal
|
||||
network with no VPN. Because it needs no Telegram reachability, **game login stays up
|
||||
even when the bot or the bot-link is down**.
|
||||
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start`
|
||||
deep-links) and `sendMessage`, the only component reaching the Telegram Bot API. It
|
||||
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
|
||||
deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
|
||||
the Telegram Bot API. It
|
||||
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
|
||||
executes the send commands the gateway pushes, so its egress can run on a host with
|
||||
native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
|
||||
@@ -49,6 +50,13 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
chat" — rather than a dangling "@"). This is otherwise **self-contained**
|
||||
— the bot never calls back into the game, so `/start` onboarding works even when the game
|
||||
is down.
|
||||
- **Support command.** `/support` replies with a fixed support-desk info message — the
|
||||
operators' working hours and what to include (a description and, when possible,
|
||||
screenshots). Like `/start` it answers only in a private chat and is **Russian or
|
||||
English** by the sender's reported language, and it is listed in the bot's command menu
|
||||
(localized, Russian/English). It is a dedicated command handler, so it intercepts
|
||||
`/support` before the support relay below — the command line itself is not forwarded to
|
||||
operators, while the user's following description still is.
|
||||
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
|
||||
group, the bot gates who may write there. The group **allows sending by default** (a
|
||||
human setting) and the bot only **restricts** — Telegram intersects the chat default with
|
||||
@@ -76,7 +84,8 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
message. State (user→topic map, block list, relayed ids) is a JSON file under
|
||||
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
|
||||
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
|
||||
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
|
||||
The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
|
||||
reply (the `/support` command above aside).
|
||||
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
|
||||
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
|
||||
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
||||
// runs the long-poll update loop — replying to /start (with an optional deep-link
|
||||
// payload) and any other message with a Mini App launch button — and sends the
|
||||
// payload), to /support with the support-desk info message, and to any other message
|
||||
// with a Mini App launch button — and sends the
|
||||
// notification and admin messages the connector requests. The bot token lives only
|
||||
// in this process.
|
||||
package bot
|
||||
@@ -136,6 +137,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
opts := []tgbot.Option{
|
||||
tgbot.WithDefaultHandler(t.handleUpdate),
|
||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
|
||||
}
|
||||
if t.supportEnabled() {
|
||||
t.supportLocks = newKeyedMutex()
|
||||
@@ -186,11 +188,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
||||
// long-poll update loop until ctx is cancelled.
|
||||
func (t *Bot) Run(ctx context.Context) {
|
||||
// Command menu: the default (fallback) list is English; a Russian-scoped list
|
||||
// localises the labels for ru clients. A language scope replaces the whole list, so
|
||||
// it repeats /start with its own Russian label.
|
||||
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
||||
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
|
||||
Commands: []models.BotCommand{
|
||||
{Command: "start", Description: "Play Scrabble"},
|
||||
{Command: "support", Description: "Contact the administration"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.log.Warn("set commands failed", zap.Error(err))
|
||||
}
|
||||
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
||||
LanguageCode: "ru",
|
||||
Commands: []models.BotCommand{
|
||||
{Command: "start", Description: "Играть в «Эрудита»"},
|
||||
{Command: "support", Description: "Связаться с администрацией"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.log.Warn("set ru commands failed", zap.Error(err))
|
||||
}
|
||||
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
||||
MenuButton: models.MenuButtonWebApp{
|
||||
Type: models.MenuButtonTypeWebApp,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// The /support command replies with a fixed support-desk info message — the operators'
|
||||
// working hours and what to include (a description and, when possible, screenshots). It
|
||||
// is a dedicated command handler (registered like /start), so it intercepts "/support"
|
||||
// before the support relay: the command line itself is not forwarded into an operator
|
||||
// topic, while the user's following description still is.
|
||||
|
||||
// handleSupport replies to /support with the localized support-desk info message. Like
|
||||
// handleStart it answers only in a private chat — in the moderated group the bot never
|
||||
// chats — and it sends a plain text message with no launch button.
|
||||
func (t *Bot) handleSupport(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
if update.Message.Chat.Type != models.ChatTypePrivate {
|
||||
return
|
||||
}
|
||||
// The sender's Telegram language rides on the message itself (Message.from.language_code);
|
||||
// fall back to English when it is absent, matching startText.
|
||||
lang := ""
|
||||
if update.Message.From != nil {
|
||||
lang = update.Message.From.LanguageCode
|
||||
}
|
||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: supportText(lang),
|
||||
}); err != nil {
|
||||
t.log.Warn("reply to support failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// supportText returns the localized /support reply. Russian is used when lang (the IETF
|
||||
// language tag the Telegram client reports on the message's sender) starts with "ru",
|
||||
// English otherwise and when it is absent — mirroring startText's language choice.
|
||||
func supportText(lang string) string {
|
||||
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||
return ruSupport
|
||||
}
|
||||
return enSupport
|
||||
}
|
||||
|
||||
// ruSupport and enSupport are the Russian and English /support replies; the English one
|
||||
// is the fallback for any non-Russian or missing sender language.
|
||||
const (
|
||||
ruSupport = "Поддержка «Эрудита» на связи с 08:00 до 18:00 (по времени UTC). " +
|
||||
"Если Вы столкнулись с проблемой, подробно опишите её и добавьте, по возможности, скриншоты. " +
|
||||
"Также будем рады выслушать Ваши пожелания и предложения по функционалу игры. " +
|
||||
"Ответим в самое ближайшее время."
|
||||
enSupport = "“Erudite” support is available from 08:00 to 18:00 (UTC). " +
|
||||
"If you have run into a problem, please describe it in detail and attach screenshots if you can. " +
|
||||
"We would also be glad to hear your wishes and suggestions about the game's features. " +
|
||||
"We will reply as soon as possible."
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
func TestHandleSupportRepliesPrivateOnly(t *testing.T) {
|
||||
t.Run("english by default", func(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
|
||||
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
|
||||
}})
|
||||
if api.chatID != "42" {
|
||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||
}
|
||||
// No reported language -> English support reply.
|
||||
if !strings.Contains(api.text, "support is available") {
|
||||
t.Errorf("text = %q, want the English support reply", api.text)
|
||||
}
|
||||
// A plain info message carries no launch button.
|
||||
if api.replyMarkup != "" {
|
||||
t.Errorf("reply_markup = %q, want none", api.replyMarkup)
|
||||
}
|
||||
})
|
||||
t.Run("russian for a ru sender", func(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
|
||||
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
|
||||
From: &models.User{ID: 7, LanguageCode: "ru"},
|
||||
}})
|
||||
if !strings.Contains(api.text, "Поддержка «Эрудита»") {
|
||||
t.Errorf("text = %q, want the Russian support reply", api.text)
|
||||
}
|
||||
})
|
||||
t.Run("group ignored", func(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
|
||||
Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/support",
|
||||
}})
|
||||
if api.chatID != "" {
|
||||
t.Errorf("group /support got a reply (chat=%q); the bot never chats in the group", api.chatID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -122,6 +122,54 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
|
||||
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
|
||||
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
|
||||
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
|
||||
|
||||
// Drive the net-state machine to an offline state through the mock hook.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
|
||||
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
|
||||
// and no server game is greyed…
|
||||
await expect(page.locator('header.nav.offline')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
|
||||
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
|
||||
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
|
||||
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
|
||||
// device-local game.
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
|
||||
page,
|
||||
}) => {
|
||||
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
|
||||
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
|
||||
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
|
||||
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
|
||||
await page.getByRole('button', { name: 'Play with friends' }).click();
|
||||
|
||||
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
|
||||
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
|
||||
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
|
||||
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
|
||||
|
||||
@@ -36,9 +36,13 @@ const DIST = 'dist';
|
||||
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
||||
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
|
||||
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
|
||||
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
|
||||
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 127, shared: 31, landing: 5 };
|
||||
// always-loaded Wallet screen — then to 130 for the offline-model redesign: the net-state machine
|
||||
// (`netstate`), the two-tier version gate (the transport interceptor + the Update/Play-offline notice
|
||||
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
|
||||
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
|
||||
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
|
||||
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 130, shared: 31, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
@@ -10,11 +10,24 @@ import { insideTelegram } from './telegram';
|
||||
import { insideVK } from './vk';
|
||||
import type { Profile } from './model';
|
||||
|
||||
/**
|
||||
* offlineCapable reports whether this channel supports the offline model — implicit offline mode,
|
||||
* device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
|
||||
* app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
|
||||
* there (offlineMode.active is forced false regardless of the detected net state, and the create flow
|
||||
* hides its device-local segment).
|
||||
*/
|
||||
export function offlineCapable(): boolean {
|
||||
return !insideTelegram() && !insideVK();
|
||||
}
|
||||
|
||||
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
|
||||
export const offlineMode = {
|
||||
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked). */
|
||||
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked) on a
|
||||
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
|
||||
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
|
||||
get active(): boolean {
|
||||
return netState.offline;
|
||||
return netState.offline && offlineCapable();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { offlineMode, offlineCapable } from '../lib/offline.svelte';
|
||||
import { localSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
||||
import { localGuestId } from '../lib/localguest';
|
||||
@@ -51,10 +51,14 @@
|
||||
];
|
||||
|
||||
const guest = $derived(app.profile?.isGuest ?? true);
|
||||
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
|
||||
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
|
||||
// channel is fixed for the session, so a plain const (not reactive) is enough.
|
||||
const canPlayOffline = offlineCapable();
|
||||
let mode = $state<'auto' | 'friends'>('auto');
|
||||
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this
|
||||
// device. Both exist regardless of connectivity, but with no network the online segment is disabled
|
||||
// and offline is forced — so the create flow always works.
|
||||
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
|
||||
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
|
||||
// offline is forced — so the create flow always works.
|
||||
let friendsMode = $state<'online' | 'offline'>('online');
|
||||
$effect(() => {
|
||||
if (offlineMode.active) friendsMode = 'offline';
|
||||
@@ -410,14 +414,17 @@
|
||||
onlogin={() => { limitOpen = false; navigate('/profile'); }}
|
||||
/>
|
||||
{:else}
|
||||
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this
|
||||
device. Both exist regardless of connectivity, but with no network the online segment is
|
||||
disabled and offline is forced (friendsMode). -->
|
||||
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
|
||||
The online/offline segment is offered only on offline-capable channels (native / plain web);
|
||||
the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
|
||||
network the online segment is disabled and offline is forced (friendsMode). -->
|
||||
{#if canPlayOffline}
|
||||
<div class="seg modes">
|
||||
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
|
||||
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
|
||||
</div>
|
||||
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
|
||||
{/if}
|
||||
{#if friendsMode === 'offline'}
|
||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||
|
||||
Reference in New Issue
Block a user