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:
@@ -16,7 +16,7 @@
|
||||
let prefs: Partial<Prefs> = {};
|
||||
|
||||
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
|
||||
const tgLink = $derived(telegramChannelLink(i18n.locale));
|
||||
const tgLink = $derived(telegramChannelLink());
|
||||
const locales: { code: Locale; label: string }[] = [
|
||||
{ code: 'en', label: '🇬🇧 English' },
|
||||
{ code: 'ru', label: '🇷🇺 Русский' },
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// Point at the bot the player signed in through (its service language), falling back to
|
||||
// the interface locale, so an ru player is sent to the ru bot and an en player to the en one.
|
||||
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
|
||||
// The single bot's @username, for the deep link.
|
||||
const username = $derived(botUsername());
|
||||
// Split the message around the {bot} token so the bot handle renders as an inline link.
|
||||
const parts = $derived(t('friends.staleInvite').split('{bot}'));
|
||||
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// Point at the bot the player signed in through (its service language), falling back to the
|
||||
// interface locale, so an ru player is sent to the ru bot and an en player to the en one.
|
||||
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
|
||||
// The single bot's @username, for the deep link.
|
||||
const username = $derived(botUsername());
|
||||
// Greet the arriving player by their own display name.
|
||||
const name = $derived(app.profile?.displayName || app.session?.displayName || '');
|
||||
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders
|
||||
|
||||
@@ -95,8 +95,20 @@ banner(obj?:BannerInfo):BannerInfo|null {
|
||||
return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
variantPreferences(index: number):string
|
||||
variantPreferences(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
variantPreferences(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 28);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
variantPreferencesLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 28);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startProfile(builder:flatbuffers.Builder) {
|
||||
builder.startObject(12);
|
||||
builder.startObject(13);
|
||||
}
|
||||
|
||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||
@@ -147,6 +159,22 @@ static addBanner(builder:flatbuffers.Builder, bannerOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(11, bannerOffset, 0);
|
||||
}
|
||||
|
||||
static addVariantPreferences(builder:flatbuffers.Builder, variantPreferencesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(12, variantPreferencesOffset, 0);
|
||||
}
|
||||
|
||||
static createVariantPreferencesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
|
||||
@@ -46,27 +46,8 @@ displayName(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
supportedLanguages(index: number):string
|
||||
supportedLanguages(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
supportedLanguages(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
supportedLanguagesLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
serviceLanguage():string|null
|
||||
serviceLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
serviceLanguage(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startSession(builder:flatbuffers.Builder) {
|
||||
builder.startObject(6);
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
|
||||
@@ -85,39 +66,17 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
|
||||
builder.addFieldOffset(3, displayNameOffset, 0);
|
||||
}
|
||||
|
||||
static addSupportedLanguages(builder:flatbuffers.Builder, supportedLanguagesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(4, supportedLanguagesOffset, 0);
|
||||
}
|
||||
|
||||
static createSupportedLanguagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addServiceLanguage(builder:flatbuffers.Builder, serviceLanguageOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(5, serviceLanguageOffset, 0);
|
||||
}
|
||||
|
||||
static endSession(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset, serviceLanguageOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
Session.startSession(builder);
|
||||
Session.addToken(builder, tokenOffset);
|
||||
Session.addUserId(builder, userIdOffset);
|
||||
Session.addIsGuest(builder, isGuest);
|
||||
Session.addDisplayName(builder, displayNameOffset);
|
||||
Session.addSupportedLanguages(builder, supportedLanguagesOffset);
|
||||
Session.addServiceLanguage(builder, serviceLanguageOffset);
|
||||
return Session.endSession(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,20 @@ notificationsInAppOnly():boolean {
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
|
||||
}
|
||||
|
||||
variantPreferences(index: number):string
|
||||
variantPreferences(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
variantPreferences(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 20);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
variantPreferencesLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 20);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startUpdateProfileRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(8);
|
||||
builder.startObject(9);
|
||||
}
|
||||
|
||||
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||
@@ -106,12 +118,28 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp
|
||||
builder.addFieldInt8(7, +notificationsInAppOnly, +true);
|
||||
}
|
||||
|
||||
static addVariantPreferences(builder:flatbuffers.Builder, variantPreferencesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(8, variantPreferencesOffset, 0);
|
||||
}
|
||||
|
||||
static createVariantPreferencesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean):flatbuffers.Offset {
|
||||
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean, variantPreferencesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
UpdateProfileRequest.startUpdateProfileRequest(builder);
|
||||
UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
|
||||
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
|
||||
@@ -121,6 +149,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
|
||||
UpdateProfileRequest.addBlockChat(builder, blockChat);
|
||||
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
|
||||
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
|
||||
UpdateProfileRequest.addVariantPreferences(builder, variantPreferencesOffset);
|
||||
return UpdateProfileRequest.endUpdateProfileRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,6 +706,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||
blockChat: p.blockChat,
|
||||
blockFriendRequests: p.blockFriendRequests,
|
||||
notificationsInAppOnly: p.notificationsInAppOnly,
|
||||
variantPreferences: p.variantPreferences,
|
||||
});
|
||||
} catch {
|
||||
// The client locale already changed; the server sync is best-effort.
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
encodeStateRequest,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
encodeUpdateProfile,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
@@ -188,21 +189,17 @@ describe('codec', () => {
|
||||
const token = b.createString('tok');
|
||||
const uid = b.createString('u1');
|
||||
const name = b.createString('Me');
|
||||
const langs = fb.Session.createSupportedLanguagesVector(b, [b.createString('en'), b.createString('ru')]);
|
||||
fb.Session.startSession(b);
|
||||
fb.Session.addToken(b, token);
|
||||
fb.Session.addUserId(b, uid);
|
||||
fb.Session.addIsGuest(b, true);
|
||||
fb.Session.addDisplayName(b, name);
|
||||
fb.Session.addSupportedLanguages(b, langs);
|
||||
b.finish(fb.Session.endSession(b));
|
||||
expect(decodeSession(b.asUint8Array())).toEqual({
|
||||
token: 'tok',
|
||||
userId: 'u1',
|
||||
isGuest: true,
|
||||
displayName: 'Me',
|
||||
supportedLanguages: ['en', 'ru'],
|
||||
serviceLanguage: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,7 +408,7 @@ describe('codec', () => {
|
||||
b.finish(fb.LinkResult.endLinkResult(b));
|
||||
const r = decodeLinkResult(b.asUint8Array());
|
||||
expect(r.status).toBe('merged');
|
||||
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [], serviceLanguage: '' });
|
||||
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya' });
|
||||
});
|
||||
|
||||
it('decodes an Invitation with inviter and invitees', () => {
|
||||
@@ -568,6 +565,35 @@ describe('codec', () => {
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decodes the profile variant preferences', () => {
|
||||
const b = new Builder(64);
|
||||
const uid = b.createString('u-1');
|
||||
const prefs = fb.Profile.createVariantPreferencesVector(b, [b.createString('erudit_ru'), b.createString('scrabble_en')]);
|
||||
fb.Profile.startProfile(b);
|
||||
fb.Profile.addUserId(b, uid);
|
||||
fb.Profile.addVariantPreferences(b, prefs);
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
|
||||
});
|
||||
|
||||
it('encodes the update-profile variant preferences', () => {
|
||||
const buf = encodeUpdateProfile({
|
||||
displayName: 'Kaya',
|
||||
preferredLanguage: 'ru',
|
||||
timeZone: 'UTC',
|
||||
awayStart: '00:00',
|
||||
awayEnd: '07:00',
|
||||
blockChat: false,
|
||||
blockFriendRequests: false,
|
||||
notificationsInAppOnly: true,
|
||||
variantPreferences: ['erudit_ru', 'scrabble_ru'],
|
||||
});
|
||||
const t = fb.UpdateProfileRequest.getRootAsUpdateProfileRequest(new ByteBuffer(buf));
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < t.variantPreferencesLength(); i++) out.push(t.variantPreferences(i));
|
||||
expect(out).toEqual(['erudit_ru', 'scrabble_ru']);
|
||||
});
|
||||
});
|
||||
|
||||
// The live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
|
||||
+15
-5
@@ -294,17 +294,13 @@ function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
|
||||
}
|
||||
|
||||
// sessionFromTable projects a FlatBuffers Session table (a root or a nested one) to
|
||||
// the Session model, including the supported-languages set the UI gates variants by.
|
||||
// the Session model.
|
||||
function sessionFromTable(t: fb.Session): Session {
|
||||
const supportedLanguages: string[] = [];
|
||||
for (let i = 0; i < t.supportedLanguagesLength(); i++) supportedLanguages.push(s(t.supportedLanguages(i)));
|
||||
return {
|
||||
token: s(t.token()),
|
||||
userId: s(t.userId()),
|
||||
isGuest: t.isGuest(),
|
||||
displayName: s(t.displayName()),
|
||||
supportedLanguages,
|
||||
serviceLanguage: s(t.serviceLanguage()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,10 +322,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
blockFriendRequests: p.blockFriendRequests(),
|
||||
isGuest: p.isGuest(),
|
||||
notificationsInAppOnly: p.notificationsInAppOnly(),
|
||||
variantPreferences: decodeVariantPreferences(p),
|
||||
banner: decodeBanner(p),
|
||||
};
|
||||
}
|
||||
|
||||
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
|
||||
// the player enabled in Settings).
|
||||
function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
||||
const out: Variant[] = [];
|
||||
for (let i = 0; i < p.variantPreferencesLength(); i++) out.push(s(p.variantPreferences(i)) as Variant);
|
||||
return out;
|
||||
}
|
||||
|
||||
// decodeBanner projects the optional advertising-banner block of a Profile, or
|
||||
// undefined when the viewer is not eligible (the field is absent).
|
||||
function decodeBanner(p: fb.Profile): Banner | undefined {
|
||||
@@ -642,6 +647,10 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
const tz = b.createString(p.timeZone);
|
||||
const as = b.createString(p.awayStart);
|
||||
const ae = b.createString(p.awayEnd);
|
||||
const prefs = fb.UpdateProfileRequest.createVariantPreferencesVector(
|
||||
b,
|
||||
p.variantPreferences.map((v) => b.createString(v)),
|
||||
);
|
||||
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
|
||||
fb.UpdateProfileRequest.addDisplayName(b, name);
|
||||
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
|
||||
@@ -651,6 +660,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
|
||||
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
|
||||
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
|
||||
fb.UpdateProfileRequest.addVariantPreferences(b, prefs);
|
||||
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
|
||||
}
|
||||
|
||||
|
||||
@@ -35,19 +35,6 @@ describe('shareLink', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
|
||||
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
|
||||
});
|
||||
|
||||
it('picks the per-bot base by language', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
|
||||
expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1');
|
||||
expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1');
|
||||
});
|
||||
|
||||
it('falls back to the language-agnostic base when the per-bot one is unset', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/fallback/app');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', '');
|
||||
expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('botUsername', () => {
|
||||
@@ -62,11 +49,4 @@ describe('botUsername', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
|
||||
expect(botUsername()).toBe('bot');
|
||||
});
|
||||
|
||||
it('picks the per-bot handle by language', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
|
||||
expect(botUsername('en')).toBe('enbot');
|
||||
expect(botUsername('ru')).toBe('rubot');
|
||||
});
|
||||
});
|
||||
|
||||
+12
-18
@@ -41,25 +41,20 @@ function envVar(name: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramBase returns the Mini App link base (e.g. https://t.me/<bot>/<app>) for a
|
||||
* bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the
|
||||
* language-agnostic VITE_TELEGRAM_LINK. Returns null when none is configured, so a
|
||||
* shared link points at the same bot the player signed in through.
|
||||
* telegramBase returns the single bot's Mini App link base (e.g. https://t.me/<bot>/<app>)
|
||||
* from VITE_TELEGRAM_LINK, or null when it is not configured.
|
||||
*/
|
||||
function telegramBase(lang: string): string | null {
|
||||
const byLang =
|
||||
lang === 'ru' ? envVar('VITE_TELEGRAM_LINK_RU') : lang === 'en' ? envVar('VITE_TELEGRAM_LINK_EN') : undefined;
|
||||
return byLang || envVar('VITE_TELEGRAM_LINK') || null;
|
||||
function telegramBase(): string | null {
|
||||
return envVar('VITE_TELEGRAM_LINK') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* botUsername extracts the bot's @username (without the @) from the configured Mini App
|
||||
* link for a bot language: the first path segment of https://t.me/<bot>/<app>. Returns
|
||||
* null when no base is configured or the link carries no path, so callers can fall back
|
||||
* when they cannot point at a specific bot.
|
||||
* link: the first path segment of https://t.me/<bot>/<app>. Returns null when no base is
|
||||
* configured or the link carries no path.
|
||||
*/
|
||||
export function botUsername(lang = ''): string | null {
|
||||
const base = telegramBase(lang);
|
||||
export function botUsername(): string | null {
|
||||
const base = telegramBase();
|
||||
if (!base) return null;
|
||||
try {
|
||||
const seg = new URL(base).pathname.split('/').filter(Boolean)[0];
|
||||
@@ -70,12 +65,11 @@ export function botUsername(lang = ''): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* shareLink wraps a deep-link start parameter in a t.me Mini App link for the given
|
||||
* bot language (the session's service language). Returns null when no base is
|
||||
* configured, so callers can hide the share affordance.
|
||||
* shareLink wraps a deep-link start parameter in a t.me Mini App link for the single
|
||||
* bot. Returns null when no base is configured, so callers can hide the share affordance.
|
||||
*/
|
||||
export function shareLink(param: string, lang = ''): string | null {
|
||||
const base = telegramBase(lang);
|
||||
export function shareLink(param: string): string | null {
|
||||
const base = telegramBase();
|
||||
if (!base) return null;
|
||||
const sep = base.includes('?') ? '&' : '?';
|
||||
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
||||
|
||||
@@ -136,6 +136,8 @@ export const en = {
|
||||
'profile.blockChat': 'Disable chat',
|
||||
'profile.blockFriendRequests': 'Disable friend requests',
|
||||
'profile.notificationsInAppOnly': 'Notifications in the app only',
|
||||
'profile.preferences': 'Preferences',
|
||||
'profile.preferencesHint': 'The game variants you can be matched into. Pick at least one.',
|
||||
'profile.email': 'Email',
|
||||
'profile.bindEmail': 'Bind email',
|
||||
'profile.emailCode': 'Confirmation code',
|
||||
|
||||
@@ -137,6 +137,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'profile.blockChat': 'Отключить чат',
|
||||
'profile.blockFriendRequests': 'Отключить заявки в друзья',
|
||||
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
|
||||
'profile.preferences': 'Предпочтения',
|
||||
'profile.preferencesHint': 'Варианты игр, в которые вас могут подобрать. Выберите хотя бы один.',
|
||||
'profile.email': 'Эл. почта',
|
||||
'profile.bindEmail': 'Привязать почту',
|
||||
'profile.emailCode': 'Код подтверждения',
|
||||
|
||||
@@ -4,17 +4,13 @@ import { telegramChannelLink } from './landing';
|
||||
describe('telegramChannelLink', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('builds the per-language t.me link from the channel name', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
|
||||
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
|
||||
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
|
||||
it('builds the t.me link from the channel name', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated
|
||||
expect(telegramChannelLink()).toBe('https://t.me/Erudit_Game');
|
||||
});
|
||||
|
||||
it('returns null when the locale channel is unset or blank', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
|
||||
expect(telegramChannelLink('en')).toBeNull();
|
||||
expect(telegramChannelLink('ru')).toBeNull();
|
||||
it('returns null when the channel name is unset or blank', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', ' ');
|
||||
expect(telegramChannelLink()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+8
-13
@@ -1,20 +1,15 @@
|
||||
// Pure helpers for the public landing page, kept out of the Svelte component so
|
||||
// the per-language Telegram-channel link selection is unit-testable.
|
||||
|
||||
import type { Locale } from './i18n/index.svelte';
|
||||
// Pure helpers for the public landing page, kept out of the Svelte component so the
|
||||
// Telegram-channel link selection is unit-testable.
|
||||
|
||||
/**
|
||||
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
|
||||
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
|
||||
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
|
||||
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
|
||||
* telegramChannelLink returns the t.me link for the single bot's game channel, or null
|
||||
* when it is not configured. The channel username is a build-time var
|
||||
* (VITE_TELEGRAM_GAME_CHANNEL_NAME) because the test and prod contours run different
|
||||
* channels; it is the same channel the connector posts to via TELEGRAM_GAME_CHANNEL_ID
|
||||
* (the id to post, the name to link). A leading "@" is tolerated.
|
||||
*/
|
||||
export function telegramChannelLink(locale: Locale): string | null {
|
||||
const raw =
|
||||
locale === 'ru'
|
||||
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
|
||||
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
|
||||
export function telegramChannelLink(): string | null {
|
||||
const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME;
|
||||
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
|
||||
return name ? `https://t.me/${name}` : null;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ export const SESSION: Session = {
|
||||
userId: ME,
|
||||
isGuest: true,
|
||||
displayName: 'You',
|
||||
// Both languages by default, so the mock-driven UI offers every variant.
|
||||
supportedLanguages: ['en', 'ru'],
|
||||
serviceLanguage: '',
|
||||
};
|
||||
|
||||
export const PROFILE: Profile = {
|
||||
@@ -40,6 +37,8 @@ export const PROFILE: Profile = {
|
||||
blockFriendRequests: false,
|
||||
isGuest: false,
|
||||
notificationsInAppOnly: true,
|
||||
// Every variant enabled, so the mock-driven UI offers the full New Game picker.
|
||||
variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'],
|
||||
};
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
|
||||
+4
-8
@@ -151,6 +151,9 @@ export interface Profile {
|
||||
isGuest: boolean;
|
||||
/** Confine notifications to the in-app stream (no out-of-app platform push). */
|
||||
notificationsInAppOnly: boolean;
|
||||
/** Variants the player allows themselves to be matched into (Erudit-first). The New
|
||||
* Game picker is gated by this set, which is never empty. */
|
||||
variantPreferences: Variant[];
|
||||
/** The advertising-banner block, present only for a viewer eligible to see it. */
|
||||
banner?: Banner;
|
||||
}
|
||||
@@ -201,6 +204,7 @@ export interface ProfileUpdate {
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
notificationsInAppOnly: boolean;
|
||||
variantPreferences: Variant[];
|
||||
}
|
||||
|
||||
/** A referenced account with its display name (friend, blocked user, invitee). */
|
||||
@@ -327,14 +331,6 @@ export interface Session {
|
||||
userId: string;
|
||||
isGuest: boolean;
|
||||
displayName: string;
|
||||
// supportedLanguages is the set of game languages the service the user signed in
|
||||
// through offers (subset of {en, ru}, at least one). New Game offers only the
|
||||
// variants these languages support (en -> English; ru -> Russian + Эрудит). Empty
|
||||
// means ungated (all variants).
|
||||
supportedLanguages: string[];
|
||||
// serviceLanguage is the en/ru tag of the Telegram bot this session was minted
|
||||
// through (empty for a non-Telegram login); used to share a link to the same bot.
|
||||
serviceLanguage: string;
|
||||
}
|
||||
|
||||
// LinkResult is the outcome of an account link/merge step. status is
|
||||
|
||||
@@ -7,22 +7,29 @@ import {
|
||||
multipleWordsForRequest,
|
||||
} from './variants';
|
||||
|
||||
describe('ALL_VARIANTS', () => {
|
||||
it('lists variants Erudit-first', () => {
|
||||
expect(ALL_VARIANTS.map((v) => v.id)).toEqual(['erudit_ru', 'scrabble_ru', 'scrabble_en']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availableVariants', () => {
|
||||
it('is ungated (all variants) for an empty or absent set', () => {
|
||||
it('is ungated (all variants) for an empty or absent preference set', () => {
|
||||
expect(availableVariants([])).toEqual(ALL_VARIANTS);
|
||||
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
|
||||
});
|
||||
|
||||
it('offers only English for an en-only service', () => {
|
||||
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']);
|
||||
it('offers only the preferred variants', () => {
|
||||
expect(availableVariants(['scrabble_en']).map((v) => v.id)).toEqual(['scrabble_en']);
|
||||
expect(availableVariants(['erudit_ru', 'scrabble_en']).map((v) => v.id)).toEqual(['erudit_ru', 'scrabble_en']);
|
||||
});
|
||||
|
||||
it('offers Russian and Эрудит for a ru-only service', () => {
|
||||
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']);
|
||||
});
|
||||
|
||||
it('offers every variant for a bilingual service', () => {
|
||||
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']);
|
||||
it('keeps the Erudit-first catalogue order regardless of preference order', () => {
|
||||
expect(availableVariants(['scrabble_en', 'erudit_ru', 'scrabble_ru']).map((v) => v.id)).toEqual([
|
||||
'erudit_ru',
|
||||
'scrabble_ru',
|
||||
'scrabble_en',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-10
@@ -11,14 +11,15 @@ export interface VariantOption {
|
||||
label: MessageKey;
|
||||
}
|
||||
|
||||
// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
|
||||
// ALL_VARIANTS lists every variant in display order (Erudit first — the default
|
||||
// preference and the product's primary variant). The labels are display names keyed by
|
||||
// the game's alphabet, not the interface language: the English-alphabet game is always
|
||||
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
|
||||
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит".
|
||||
export const ALL_VARIANTS: VariantOption[] = [
|
||||
{ id: 'scrabble_en', label: 'new.english' },
|
||||
{ id: 'scrabble_ru', label: 'new.russian' },
|
||||
{ id: 'erudit_ru', label: 'new.erudit' },
|
||||
{ id: 'scrabble_ru', label: 'new.russian' },
|
||||
{ id: 'scrabble_en', label: 'new.english' },
|
||||
];
|
||||
|
||||
// variantNameKey returns the i18n key for a variant's display name (used by the in-game
|
||||
@@ -47,13 +48,13 @@ export const VARIANT_FLAG: Record<Variant, string> = {
|
||||
// ru -> Russian + Эрудит.
|
||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
|
||||
|
||||
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
|
||||
// or absent set is ungated (a web/legacy session without a declared set), returning
|
||||
// every variant.
|
||||
export function availableVariants(supportedLanguages: string[] | undefined): VariantOption[] {
|
||||
const langs = supportedLanguages ?? [];
|
||||
if (langs.length === 0) return ALL_VARIANTS;
|
||||
return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id]));
|
||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
||||
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
||||
// — a safety fallback; a real profile always carries at least one preference.
|
||||
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
|
||||
const prefs = preferences ?? [];
|
||||
if (prefs.length === 0) return ALL_VARIANTS;
|
||||
return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
|
||||
}
|
||||
|
||||
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
|
||||
|
||||
@@ -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