feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
This commit is contained in:
Ilia Denisov
2026-06-20 14:08:27 +02:00
parent 1933849dba
commit 57c778f9b2
99 changed files with 1006 additions and 1256 deletions
+5 -9
View File
@@ -4,8 +4,7 @@
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
import { localeFrom, t } from '../lib/i18n/index.svelte';
import { translate } from '../lib/i18n/catalog';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
@@ -95,10 +94,8 @@
// shareInvite shares the friend-code deep link: inside Telegram via the native
// "share to chat" picker; on the web via the system share sheet; failing both, it
// copies the link to the clipboard.
async function shareInvite(url: string, lang: string) {
// The caption is in the bot's language (Эрудит for ru, Scrabble for en), not the
// interface language — the recipient lands in that bot.
const text = translate(localeFrom(lang), 'friends.inviteText');
async function shareInvite(url: string) {
const text = t('friends.inviteText');
if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
@@ -134,8 +131,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const lang = app.session?.serviceLanguage || app.locale}
{@const tg = shareLink(friendCodeParam(code.code), lang)}
{@const tg = shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -145,7 +141,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<button type="button" class="link tgshare" onclick={() => shareInvite(tg, lang)}>{t('friends.shareTelegram')}</button>
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}
+3 -3
View File
@@ -18,9 +18,9 @@
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
// The offered variants are gated by the languages the sign-in service supports;
// the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages));
// The offered variants are gated by the player's profile preferences (the variants
// they enabled in Settings); the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.profile?.variantPreferences));
// "Multiple words per turn" off is the single-word rule; it is offered for Russian games
// only (English is always standard and shows no toggle). Shared by both flows.
let multipleWords = $state(false);
+56 -1
View File
@@ -16,6 +16,8 @@
validDisplayName,
validEmail,
} from '../lib/profileValidation';
import { ALL_VARIANTS, VARIANT_RULES } from '../lib/variants';
import type { Variant } from '../lib/model';
let dn = $state('');
let tz = $state('+00:00');
@@ -28,6 +30,7 @@
let blockChat = $state(false);
let blockFriendRequests = $state(false);
let notificationsInAppOnly = $state(true);
let variantPrefs = $state<Variant[]>([]);
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
@@ -60,6 +63,7 @@
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
variantPrefs = [...p.variantPreferences];
}
onMount(populate);
@@ -67,9 +71,19 @@
const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk);
const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
const emailOk = $derived(validEmail(emailInput));
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant.
function toggleVariant(id: Variant) {
if (variantPrefs.includes(id)) {
if (variantPrefs.length > 1) variantPrefs = variantPrefs.filter((v) => v !== id);
} else {
variantPrefs = [...variantPrefs, id];
}
}
async function save() {
if (!formValid) return;
try {
@@ -82,6 +96,7 @@
blockChat,
blockFriendRequests,
notificationsInAppOnly,
variantPreferences: variantPrefs,
});
showToast(t('profile.saved'));
} catch (e) {
@@ -207,6 +222,22 @@
<input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<fieldset class="prefs">
<legend>{t('profile.preferences')}</legend>
<p class="muted">{t('profile.preferencesHint')}</p>
{#each ALL_VARIANTS as v (v.id)}
<label class="check pref">
<input
type="checkbox"
checked={variantPrefs.includes(v.id)}
disabled={variantPrefs.length === 1 && variantPrefs.includes(v.id)}
onchange={() => toggleVariant(v.id)}
/>
<span class="pname">{t(v.label)}</span>
<span class="prule">{t(VARIANT_RULES[v.id])}</span>
</label>
{/each}
</fieldset>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
</div>
@@ -342,6 +373,30 @@
gap: 10px !important;
color: var(--text) !important;
}
.prefs {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.prefs legend {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0 4px;
}
.pref {
align-items: baseline;
}
.pname {
font-weight: 600;
}
.prule {
color: var(--text-muted);
font-size: 0.8rem;
}
.formacts {
display: flex;
gap: 10px;