From 1ba789a1f1af872ea8deb877fd70ee7ae1a35b69 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 17:15:10 +0200 Subject: [PATCH] docs(telegram): invert chat-gate strategy in docs; tune logs; i18n text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bake the final default-allow + mute-the-ineligible strategy into docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), platform/telegram/README.md, the deploy compose comment and the PRERELEASE tracker. The live test proved a per-user grant cannot exceed a deny-by-default group (Telegram intersects the chat default with the per-user permission), so the chat allows sending by default and the bot restricts the ineligible instead of granting the eligible. - Lower the per-event chat_member trace and eligibility evaluation to Debug; keep the actual mute/unmute actions, the startup self-check and warnings at Info, so prod logs only what the bot did. - Update game.searchingForOpponent (Searching -> Waiting for opponent / Поиск -> Ждём соперника) and the quickmatch e2e assertions to match. --- PRERELEASE.md | 26 +++++++++++++++----------- deploy/docker-compose.yml | 5 +++-- docs/ARCHITECTURE.md | 18 +++++++++++------- docs/FUNCTIONAL.md | 14 +++++++------- docs/FUNCTIONAL_ru.md | 15 ++++++++------- platform/telegram/README.md | 22 +++++++++++++--------- platform/telegram/internal/bot/bot.go | 6 +++--- ui/e2e/quickmatch.spec.ts | 16 ++++++++-------- ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- 10 files changed, 70 insertions(+), 56 deletions(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index e7f3b80..b1239e8 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -41,7 +41,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) | | AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) | -| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat — granting on join when the Telegram user is registered and neither admin-suspended nor holding a new **`chat_muted`** role, and revoking/granting on the matching moderation change for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's join-time unary `ResolveChatEligibility` over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (idempotent, so a temporary-block-expiry sweeper may over-emit). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** | +| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) @@ -611,13 +611,17 @@ Then Stage 18. the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot `chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config parsing. - - **Post-contour-test fixes (same PR):** a live test surfaced gaps. (1) **Join detection** — a - default-deny discussion group reports a present member as `restricted`, not `member`, so the grant - trigger now fires for any eligible in-chat member (`member` or `restricted`) still lacking the send - right, with a loop guard (skip when send is already allowed, so the bot's own grant does not - re-fire); revoking a now-ineligible user stays the chat-gate path's job, so this never fights a - `chat_muted`/block. (2) **Join-before-register** — a user who joins before registering is covered by - no `chat_member` event, so `ProvisionTelegram` now reports first contact and the Telegram auth - handler emits `chat_access_changed` on it. (3) **Observability** — a startup self-check logs whether - the bot is an admin-with-restrict in the chat (it caught a misconfigured `TELEGRAM_CHAT_ID` set to a - channel id instead of the discussion-group id), plus per-event grant-path logging. + - **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy + inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot + work: Telegram intersects the chat default with each user's permission, so a per-user grant never + exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write). + The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible + member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted, + acting only when the current state differs (idempotent; the bot's own change is skipped by matching + the actor id to the bot). A present member in a default-allow group can appear as `restricted` with + `is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before + registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact + and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup + self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured + `TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at + Debug, the actual mute/unmute and warnings at Info. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e18fd1f..cd17de5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -269,8 +269,9 @@ services: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} # The moderated discussion chat (a channel's linked group) the bot gates write - # access in. Empty disables gating; the bot must be an admin there with the - # "Ban users" right and receives chat_member updates only as an admin. + # access in. Empty disables gating. The group must ALLOW sending by default — the bot + # only restricts (mutes the ineligible) — and the bot must be an admin there with the + # "Ban users" right; chat_member updates are delivered only to a chat admin. TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} # The 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b27b7d5..991a19a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -997,13 +997,17 @@ sweeper for the gate — it recomputes against `now`). No operator identity is r Basic-Auth). **Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot -(`TELEGRAM_CHAT_ID`): the group defaults to no-send, and a user may write only while they are -**registered and neither admin-suspended nor holding the chat-only `chat_muted` role** -(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates). A -single backend resolver behind `POST /api/v1/internal/chat-access` answers both directions: the -bot's join-time `ResolveChatEligibility` (over the mTLS bot-link) grants write access to an -eligible joiner, and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` -grant/revoke, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no +(`TELEGRAM_CHAT_ID`). The group **allows sending by default** and the bot only **restricts**: Telegram +intersects the chat default with each user's permission, so a per-user grant can never exceed a +deny-by-default group — the gate must mute the ineligible, not grant the eligible. A user may write +while they are **registered and neither admin-suspended nor holding the chat-only `chat_muted` role** +(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates); the bot +**mutes** an ineligible member and **un-mutes** an eligible one it had muted, leaving an already-allowed +eligible member untouched (it acts only when the current state differs, so it is idempotent and never +loops on its own change). A single backend resolver behind `POST /api/v1/internal/chat-access` answers +both directions: the bot's `ResolveChatEligibility` on a `chat_member` event (over the mTLS bot-link), +and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` grant/revoke, a first +Telegram registration, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 07a63e6..c6206c6 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -323,13 +323,13 @@ plus the reason when one was given, and the app stops all background traffic wit temporary block lifts itself when it expires; the operator can also **unblock** from the user card at any time (games already lost stay lost). -Where the bot manages a channel's **linked discussion chat**, only a **registered** player who is -**not blocked** may write there: the bot grants the right to write when such a player joins, while an -unregistered or blocked one stays muted (the promo bot points newcomers at the game so they register). -An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — -without a full account block; an account block mutes them in the chat regardless. Muting/unmuting and -blocking/unblocking take effect for a player already in the chat; one who is not in it is unaffected -until they next join. +Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the +bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register +or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the +game to register, after which the bot restores their voice), and a registered, unblocked player simply +writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — +without a full account block; an account block mutes them in the chat regardless. Muting and unmuting +take effect for a player already in the chat; one who is not in it is unaffected until they next join. From the user card the operator can also **top up a player's hint wallet**: an additive grant (1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** — diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a43119a..b5058fc 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -332,13 +332,14 @@ high-rate флага. С карточки пользователя операт истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент (уже проигранные партии не возвращаются). -Там, где бот ведёт **привязанный к каналу чат-обсуждение**, писать в нём может только -**зарегистрированный** игрок, который **не заблокирован**: при входе такого игрока бот выдаёт право -писать, а незарегистрированному или заблокированному — оставляет немым (промо-бот направляет -новичков в игру, чтобы они зарегистрировались). Оператор также может **замьютить игрока только в -чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка -аккаунта всё равно мьютит его в чате. Мьют/размьют и блокировка/разблокировка срабатывают для -игрока, уже находящегося в чате; того, кого в чате нет, это не затрагивает до его следующего входа. +Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот +**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот +зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат, +бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а +зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только +в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка +аккаунта всё равно мьютит его в чате. Мьют и размьют срабатывают для игрока, уже находящегося в чате; +того, кого в чате нет, это не затрагивает до его следующего входа. С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 147f5e4..cdbee87 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -43,15 +43,19 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC code. This is **self-contained** — the bot never calls back into the game, so `/start` onboarding works even when the game is down. - **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion - group, the bot gates who may write there. The group is configured **default no-send** - (a human setting); the bot grants write access to a user who **joins** when they are - registered and neither admin-suspended nor `chat_muted`, asking the gateway over the - bot-link (`ResolveChatEligibility`). When an operator blocks/unblocks an account or - toggles its `chat_muted` role, the gateway pushes a `ChatGate` command and the bot - applies it — but only to a member currently in the chat (it probes one user with - `getChatMember`, since bots cannot list members). The bot must be an **administrator** - there with the **"Ban users"** right (the Bot API `can_restrict_members`), and it - subscribes to `chat_member` updates, which Telegram delivers only to a chat admin. + 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 + each user's permission, so a per-user grant cannot exceed a deny-by-default group, and the + gate must mute the ineligible rather than grant the eligible. On a `chat_member` event the + bot asks the gateway (`ResolveChatEligibility`) and **mutes** a member who is not registered + or is admin-suspended or `chat_muted`, **un-mutes** an eligible one it had muted, and leaves + an already-allowed eligible member untouched (it acts only when the state differs, so it is + idempotent and skips its own change). When an operator blocks/unblocks an account, toggles + its `chat_muted` role, or a user first registers, the gateway pushes a `ChatGate` command and + the bot applies it — but only to a member currently in the chat (it probes one user with + `getChatMember`, since bots cannot list members). The bot must be an **administrator** there + with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to + `chat_member` updates, which Telegram delivers only to a chat admin. - **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also runs a **second, standalone** bot whose only job is to answer `/start` with a localized message and a button that opens the **main** bot's Mini App. The button is a **URL** to diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index cb1dc85..4b5e56d 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -270,7 +270,7 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated // Telegram delivers joins, for which chat, the transition, who performed it, and the // new member's send/membership state. canSend, isMember := restrictedSendState(cm.NewChatMember) - t.log.Info("chat_member update", + t.log.Debug("chat_member update", zap.Int64("chat_id", cm.Chat.ID), zap.Int64("configured_chat_id", t.chatID), zap.Int64("user_id", uid), @@ -318,7 +318,7 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) return } - t.log.Info("chat access evaluated", + t.log.Debug("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend)) // Desired: an eligible user may send, an ineligible one may not. Act only when the // current state differs — idempotent, a no-op for the common eligible member, and it @@ -356,7 +356,7 @@ func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow)) return true, nil default: - t.log.Info("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type))) + t.log.Debug("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type))) return false, nil // absent, or an admin/owner who cannot be restricted } } diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 3561745..36c27f1 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -19,7 +19,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async // Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the // history panel) is disabled. - await expect(page.getByText(/Searching for opponent/)).toBeVisible(); + await expect(page.getByText(/Waiting for opponent/)).toBeVisible(); await page.locator('.scoreboard').click(); // open the history panel await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled(); @@ -28,7 +28,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async // The opponent card shows its name, the placeholder is gone, and resign is enabled again. await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled(); }); @@ -48,7 +48,7 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a // The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖. await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); // An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary // alone, which stays usable. @@ -96,7 +96,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise { @@ -106,7 +106,7 @@ test('quick game: a poll recovers a join missed while the live stream is down', await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop()); await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); }); test('quick game: a stream reconnect recovers a join missed while it was down', async ({ page }) => { @@ -117,7 +117,7 @@ test('quick game: a stream reconnect recovers a join missed while it was down', await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); await page.evaluate(() => (window as unknown as { __stream: { restore(): void } }).__stream.restore()); await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); }); test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => { @@ -125,11 +125,11 @@ test('quick game: a foreground resync recovers a join shed while the stream stay // The opponent joins but the event is shed with the stream still alive (no reconnect, and the // poll only runs while the stream is down): nothing has recovered yet. await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently()); - await expect(page.getByText(/Searching for opponent/)).toBeVisible(); + await expect(page.getByText(/Waiting for opponent/)).toBeVisible(); // Returning to the foreground resyncs the open game (pageshow drives goForeground). await page.evaluate(() => window.dispatchEvent(new Event('pageshow'))); await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); }); // Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2e957fb..132915d 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -63,7 +63,7 @@ export const en = { 'game.yourTurn': 'Your turn', 'game.yourTurnBy': '{name}: Your turn!', 'game.opponentsTurn': "Opponent's turn", - 'game.searchingForOpponent': 'Searching for opponent…', + 'game.searchingForOpponent': 'Waiting for opponent…', 'game.waiting': "Waiting for {name}", 'game.makeMove': 'Make move', 'game.reset': 'Reset', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 53a4082..a94aaac 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -64,7 +64,7 @@ export const ru: Record = { 'game.yourTurn': 'Ваш ход', 'game.yourTurnBy': '{name}: Ваш ход!', 'game.opponentsTurn': 'Ход соперника', - 'game.searchingForOpponent': 'Поиск соперника...', + 'game.searchingForOpponent': 'Ждём соперника…', 'game.waiting': 'Ожидаем {name}', 'game.makeMove': 'Сделать ход', 'game.reset': 'Сброс',