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
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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user