From 2d8e0491cca492a11260cd4e46e6fdcaf2485acf Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 13:17:46 +0400 Subject: [PATCH] feat(bot): persist locale preferences across mini app and replies --- apps/bot/src/anonymous-feedback.test.ts | 49 ++++-- apps/bot/src/anonymous-feedback.ts | 46 +++-- apps/bot/src/bot-locale.ts | 45 +++++ apps/bot/src/bot.ts | 20 ++- apps/bot/src/finance-commands.ts | 57 ++++-- apps/bot/src/household-setup.test.ts | 6 +- apps/bot/src/household-setup.ts | 76 ++++++-- apps/bot/src/index.ts | 24 ++- apps/bot/src/miniapp-admin.test.ts | 38 +++- apps/bot/src/miniapp-auth.test.ts | 36 +++- apps/bot/src/miniapp-auth.ts | 13 +- apps/bot/src/miniapp-dashboard.test.ts | 19 +- apps/bot/src/miniapp-locale.test.ts | 212 +++++++++++++++++++++++ apps/bot/src/miniapp-locale.ts | 150 ++++++++++++++++ apps/bot/src/purchase-topic-ingestion.ts | 12 +- apps/bot/src/server.ts | 12 ++ apps/miniapp/src/App.tsx | 121 ++++++++++++- apps/miniapp/src/i18n.ts | 4 + apps/miniapp/src/miniapp-api.ts | 41 +++++ 19 files changed, 904 insertions(+), 77 deletions(-) create mode 100644 apps/bot/src/bot-locale.ts create mode 100644 apps/bot/src/miniapp-locale.test.ts create mode 100644 apps/bot/src/miniapp-locale.ts diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 2a2a52e..dca2b72 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -95,7 +95,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit householdName: 'Kojori House', telegramChatId: '-100222333', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' } }), getTelegramHouseholdChat: async () => ({ @@ -103,14 +104,16 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit householdName: 'Kojori House', telegramChatId: '-100222333', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' }), getHouseholdChatByHouseholdId: async () => ({ householdId: 'household-1', householdName: 'Kojori House', telegramChatId: '-100222333', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' }), bindHouseholdTopic: async (input) => ({ householdId: input.householdId, @@ -143,7 +146,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: 'ru' }), getPendingHouseholdMember: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null, @@ -152,6 +156,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: 'ru', isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, @@ -162,11 +168,30 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit householdId: 'household-1', telegramUserId: '123456', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: false } ], listPendingHouseholdMembers: async () => [], - approvePendingHouseholdMember: async () => null + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100222333', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: locale + }), + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => ({ + id: `member-${telegramUserId}`, + householdId: 'household-1', + telegramUserId, + displayName: 'Stan', + preferredLocale: locale, + householdDefaultLocale: 'ru', + isAdmin: false + }) } } @@ -236,10 +261,10 @@ describe('registerAnonymousFeedback', () => { expect(calls[0]?.payload).toMatchObject({ chat_id: '-100222333', message_thread_id: 77, - text: 'Anonymous household note\n\nPlease clean the kitchen tonight.' + text: 'Анонимное сообщение по дому\n\nPlease clean the kitchen tonight.' }) expect(calls[1]?.payload).toMatchObject({ - text: 'Anonymous feedback delivered.' + text: 'Анонимное сообщение отправлено.' }) }) @@ -303,7 +328,7 @@ describe('registerAnonymousFeedback', () => { expect(calls).toHaveLength(1) expect(calls[0]?.payload).toMatchObject({ - text: 'Use /anon in a private chat with the bot.' + text: 'Используйте /anon в личном чате с ботом.' }) }) @@ -377,15 +402,15 @@ describe('registerAnonymousFeedback', () => { expect(submit).toHaveBeenCalledTimes(1) expect(calls[0]?.payload).toMatchObject({ - text: 'Send me the anonymous message in your next reply, or tap Cancel.' + text: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».' }) expect(calls[1]?.payload).toMatchObject({ chat_id: '-100222333', message_thread_id: 77, - text: 'Anonymous household note\n\nPlease clean the kitchen tonight.' + text: 'Анонимное сообщение по дому\n\nPlease clean the kitchen tonight.' }) expect(calls[2]?.payload).toMatchObject({ - text: 'Anonymous feedback delivered.' + text: 'Анонимное сообщение отправлено.' }) }) @@ -620,7 +645,7 @@ describe('registerAnonymousFeedback', () => { expect(calls).toHaveLength(1) expect(calls[0]?.payload).toMatchObject({ - text: 'Anonymous feedback cooldown is active. You can send the next message in 6 hours.' + text: 'Сейчас действует пауза на анонимные сообщения. Следующее сообщение можно отправить через 6 часов.' }) }) }) diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts index 5fee009..b554e95 100644 --- a/apps/bot/src/anonymous-feedback.ts +++ b/apps/bot/src/anonymous-feedback.ts @@ -7,7 +7,8 @@ import type { } from '@household/ports' import type { Bot, Context } from 'grammy' -import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' +import { getBotTranslations, type BotLocale } from './i18n' +import { resolveReplyLocale } from './bot-locale' const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback' @@ -114,9 +115,13 @@ async function clearPendingAnonymousFeedbackPrompt( async function startPendingAnonymousFeedbackPrompt( repository: TelegramPendingActionRepository, + householdConfigurationRepository: HouseholdConfigurationRepository, ctx: Context ): Promise { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: householdConfigurationRepository + }) const t = getBotTranslations(locale).anonymousFeedback const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() @@ -152,11 +157,15 @@ async function submitAnonymousFeedback(options: { const telegramMessageId = options.ctx.msg?.message_id?.toString() const telegramUpdateId = 'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined - const locale = botLocaleFromContext(options.ctx) - const t = getBotTranslations(locale).anonymousFeedback + const fallbackLocale = await resolveReplyLocale({ + ctx: options.ctx, + repository: options.householdConfigurationRepository + }) if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) { - await options.ctx.reply(t.unableToIdentifyMessage) + await options.ctx.reply( + getBotTranslations(fallbackLocale).anonymousFeedback.unableToIdentifyMessage + ) return } @@ -167,17 +176,19 @@ async function submitAnonymousFeedback(options: { if (memberships.length === 0) { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply(t.notMember) + await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.notMember) return } if (memberships.length > 1) { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply(t.multipleHouseholds) + await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.multipleHouseholds) return } const member = memberships[0]! + const locale = member.preferredLocale ?? member.householdDefaultLocale + const t = getBotTranslations(locale).anonymousFeedback const householdChat = await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId) const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding( @@ -273,7 +284,10 @@ export function registerAnonymousFeedback(options: { logger?: Logger }): void { options.bot.command('cancel', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale).anonymousFeedback if (!isPrivateChat(ctx)) { return @@ -297,7 +311,10 @@ export function registerAnonymousFeedback(options: { }) options.bot.command('anon', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale).anonymousFeedback if (!isPrivateChat(ctx)) { await ctx.reply(t.useInPrivateChat) @@ -306,7 +323,11 @@ export function registerAnonymousFeedback(options: { const rawText = commandArgText(ctx) if (rawText.length === 0) { - await startPendingAnonymousFeedbackPrompt(options.promptRepository, ctx) + await startPendingAnonymousFeedbackPrompt( + options.promptRepository, + options.householdConfigurationRepository, + ctx + ) return } @@ -351,7 +372,10 @@ export function registerAnonymousFeedback(options: { }) options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale).anonymousFeedback if (!isPrivateChat(ctx)) { await ctx.answerCallbackQuery({ diff --git a/apps/bot/src/bot-locale.ts b/apps/bot/src/bot-locale.ts new file mode 100644 index 0000000..5dc2899 --- /dev/null +++ b/apps/bot/src/bot-locale.ts @@ -0,0 +1,45 @@ +import { normalizeSupportedLocale } from '@household/domain' +import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports' +import type { Context } from 'grammy' + +import { resolveBotLocale, type BotLocale } from './i18n' + +function localeFromMember(member: HouseholdMemberRecord, fallback: BotLocale): BotLocale { + return member.preferredLocale ?? member.householdDefaultLocale ?? fallback +} + +export async function resolveReplyLocale(options: { + ctx: Pick + repository: HouseholdConfigurationRepository | undefined + householdId?: string +}): Promise { + const fallback = resolveBotLocale(options.ctx.from?.language_code) + const telegramUserId = options.ctx.from?.id?.toString() + const telegramChatId = options.ctx.chat?.id?.toString() + + if (!options.repository) { + return fallback + } + + if (options.ctx.chat && options.ctx.chat.type !== 'private' && telegramChatId) { + const household = await options.repository.getTelegramHouseholdChat(telegramChatId) + return household?.defaultLocale ?? fallback + } + + if (!telegramUserId) { + return fallback + } + + if (options.householdId) { + const member = await options.repository.getHouseholdMember(options.householdId, telegramUserId) + return member ? localeFromMember(member, fallback) : fallback + } + + const members = await options.repository.listHouseholdMembersByTelegramUserId(telegramUserId) + if (members.length === 1) { + return localeFromMember(members[0]!, fallback) + } + + const normalized = normalizeSupportedLocale(options.ctx.from?.language_code) + return normalized ?? fallback +} diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 7d69489..6fd8333 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,19 +1,31 @@ import { Bot } from 'grammy' import type { Logger } from '@household/observability' +import type { HouseholdConfigurationRepository } from '@household/ports' -import { botLocaleFromContext, getBotTranslations } from './i18n' +import { getBotTranslations } from './i18n' +import { resolveReplyLocale } from './bot-locale' import { formatTelegramHelpText } from './telegram-commands' -export function createTelegramBot(token: string, logger?: Logger): Bot { +export function createTelegramBot( + token: string, + logger?: Logger, + householdConfigurationRepository?: HouseholdConfigurationRepository +): Bot { const bot = new Bot(token) bot.command('help', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: householdConfigurationRepository + }) await ctx.reply(formatTelegramHelpText(locale)) }) bot.command('household_status', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: householdConfigurationRepository + }) await ctx.reply(getBotTranslations(locale).bot.householdStatusPending) }) diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index 596c199..46d4524 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -2,7 +2,8 @@ import type { FinanceCommandService } from '@household/application' import type { HouseholdConfigurationRepository } from '@household/ports' import type { Bot, Context } from 'grammy' -import { botLocaleFromContext, getBotTranslations } from './i18n' +import { getBotTranslations } from './i18n' +import { resolveReplyLocale } from './bot-locale' function commandArgs(ctx: Context): string[] { const raw = typeof ctx.match === 'string' ? ctx.match.trim() : '' @@ -24,10 +25,10 @@ export function createFinanceCommandsService(options: { register: (bot: Bot) => void } { function formatStatement( - ctx: Context, + locale: Parameters[0], dashboard: NonNullable>> ): string { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const t = getBotTranslations(locale).finance return [ t.statementTitle(dashboard.period), @@ -42,7 +43,11 @@ export function createFinanceCommandsService(options: { service: FinanceCommandService householdId: string } | null> { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance if (!isGroupChat(ctx)) { await ctx.reply(t.useInGroup) return null @@ -63,7 +68,11 @@ export function createFinanceCommandsService(options: { } async function requireMember(ctx: Context) { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const telegramUserId = ctx.from?.id?.toString() if (!telegramUserId) { await ctx.reply(t.unableToIdentifySender) @@ -89,7 +98,11 @@ export function createFinanceCommandsService(options: { } async function requireAdmin(ctx: Context) { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const resolved = await requireMember(ctx) if (!resolved) { return null @@ -105,7 +118,11 @@ export function createFinanceCommandsService(options: { function register(bot: Bot): void { bot.command('cycle_open', async (ctx) => { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -126,7 +143,11 @@ export function createFinanceCommandsService(options: { }) bot.command('cycle_close', async (ctx) => { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -146,7 +167,11 @@ export function createFinanceCommandsService(options: { }) bot.command('rent_set', async (ctx) => { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -172,7 +197,11 @@ export function createFinanceCommandsService(options: { }) bot.command('utility_add', async (ctx) => { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -205,7 +234,11 @@ export function createFinanceCommandsService(options: { }) bot.command('statement', async (ctx) => { - const t = getBotTranslations(botLocaleFromContext(ctx)).finance + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).finance const resolved = await requireMember(ctx) if (!resolved) { return @@ -218,7 +251,7 @@ export function createFinanceCommandsService(options: { return } - await ctx.reply(formatStatement(ctx, dashboard)) + await ctx.reply(formatStatement(locale, dashboard)) } catch (error) { await ctx.reply(t.statementFailed((error as Error).message)) } diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 7533024..dddaa16 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -147,7 +147,8 @@ describe('registerHouseholdSetupCommands', () => { status: 'pending', household: { id: 'household-1', - name: 'Kojori House' + name: 'Kojori House', + defaultLocale: 'ru' } } } @@ -236,7 +237,8 @@ describe('registerHouseholdSetupCommands', () => { status: 'pending', household: { id: 'household-1', - name: 'Kojori House' + name: 'Kojori House', + defaultLocale: 'ru' } } } diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 9cbe849..763d786 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -1,12 +1,15 @@ import type { HouseholdAdminService, HouseholdOnboardingService, - HouseholdSetupService + HouseholdSetupService, + HouseholdMiniAppAccess } from '@household/application' import type { Logger } from '@household/observability' +import type { HouseholdConfigurationRepository } from '@household/ports' import type { Bot, Context } from 'grammy' -import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' +import { getBotTranslations, type BotLocale } from './i18n' +import { resolveReplyLocale } from './bot-locale' const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' @@ -175,12 +178,17 @@ export function registerHouseholdSetupCommands(options: { householdSetupService: HouseholdSetupService householdOnboardingService: HouseholdOnboardingService householdAdminService: HouseholdAdminService + householdConfigurationRepository?: HouseholdConfigurationRepository miniAppUrl?: string logger?: Logger }): void { options.bot.command('start', async (ctx) => { - const locale = botLocaleFromContext(ctx) - const t = getBotTranslations(locale) + const fallbackLocale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + let locale = fallbackLocale + let t = getBotTranslations(locale) if (ctx.chat?.type !== 'private') { return @@ -231,6 +239,18 @@ export function registerHouseholdSetupCommands(options: { return } + if (result.status === 'active') { + locale = result.member.preferredLocale ?? result.member.householdDefaultLocale + t = getBotTranslations(locale) + } else { + const access = await options.householdOnboardingService.getMiniAppAccess({ + identity, + joinToken + }) + locale = localeFromAccess(access, fallbackLocale) + t = getBotTranslations(locale) + } + if (result.status === 'active') { await ctx.reply( t.setup.alreadyActiveMember(result.member.displayName), @@ -246,8 +266,12 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('setup', async (ctx) => { - const locale = botLocaleFromContext(ctx) - const t = getBotTranslations(locale) + const fallbackLocale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + let locale = fallbackLocale + let t = getBotTranslations(locale) if (!isGroupChat(ctx)) { await ctx.reply(t.setup.useSetupInGroup) @@ -278,6 +302,9 @@ export function registerHouseholdSetupCommands(options: { return } + locale = result.household.defaultLocale + t = getBotTranslations(locale) + options.logger?.info( { event: 'household_setup.chat_registered', @@ -326,7 +353,10 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('bind_purchase_topic', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale) if (!isGroupChat(ctx)) { @@ -373,7 +403,10 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('bind_feedback_topic', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale) if (!isGroupChat(ctx)) { @@ -420,7 +453,10 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('pending_members', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale) if (!isGroupChat(ctx)) { @@ -456,7 +492,10 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('approve_member', async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale) if (!isGroupChat(ctx)) { @@ -493,7 +532,10 @@ export function registerHouseholdSetupCommands(options: { options.bot.callbackQuery( new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`), async (ctx) => { - const locale = botLocaleFromContext(ctx) + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) const t = getBotTranslations(locale) if (!isGroupChat(ctx)) { @@ -554,3 +596,15 @@ export function registerHouseholdSetupCommands(options: { } ) } + +function localeFromAccess(access: HouseholdMiniAppAccess, fallback: BotLocale): BotLocale { + switch (access.status) { + case 'active': + return access.member.preferredLocale ?? access.member.householdDefaultLocale + case 'pending': + case 'join_required': + return access.household.defaultLocale + case 'open_from_group': + return fallback + } +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index a6a6014..3a4c902 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -5,6 +5,7 @@ import { createHouseholdAdminService, createFinanceCommandService, createHouseholdOnboardingService, + createLocalePreferenceService, createMiniAppAdminService, createHouseholdSetupService, createReminderJobService @@ -37,6 +38,7 @@ import { createMiniAppApproveMemberHandler, createMiniAppPendingMembersHandler } from './miniapp-admin' +import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' const runtime = getBotRuntimeConfig() configureLogger({ @@ -45,13 +47,16 @@ configureLogger({ }) const logger = getLogger('runtime') -const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram')) -const webhookHandler = webhookCallback(bot, 'std/http') - const shutdownTasks: Array<() => Promise> = [] const householdConfigurationRepositoryClient = runtime.databaseUrl ? createDbHouseholdConfigurationRepository(runtime.databaseUrl) : null +const bot = createTelegramBot( + runtime.telegramBotToken, + getLogger('telegram'), + householdConfigurationRepositoryClient?.repository +) +const webhookHandler = webhookCallback(bot, 'std/http') const financeRepositoryClients = new Map>() const financeServices = new Map>() const householdOnboardingService = householdConfigurationRepositoryClient @@ -62,6 +67,9 @@ const householdOnboardingService = householdConfigurationRepositoryClient const miniAppAdminService = householdConfigurationRepositoryClient ? createMiniAppAdminService(householdConfigurationRepositoryClient.repository) : null +const localePreferenceService = householdConfigurationRepositoryClient + ? createLocalePreferenceService(householdConfigurationRepositoryClient.repository) + : null const telegramPendingActionRepositoryClient = runtime.databaseUrl && runtime.anonymousFeedbackEnabled ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) @@ -168,6 +176,7 @@ if (householdConfigurationRepositoryClient) { householdConfigurationRepositoryClient.repository ), householdOnboardingService: householdOnboardingService!, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, ...(runtime.miniAppAllowedOrigins[0] ? { miniAppUrl: runtime.miniAppAllowedOrigins[0] @@ -279,6 +288,15 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppLocalePreference: householdOnboardingService + ? createMiniAppLocalePreferenceHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + localePreferenceService: localePreferenceService!, + logger: getLogger('miniapp-admin') + }) + : undefined, scheduler: reminderJobs && runtime.schedulerSharedSecret ? { diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index a5fd324..ff98f88 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -18,7 +18,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdName: 'Kojori House', telegramChatId: '-100123', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' as const } return { @@ -52,7 +53,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale }), getPendingHouseholdMember: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null, @@ -61,6 +63,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdId: household.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, @@ -73,7 +77,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { telegramUserId: '555777', displayName: 'Mia', username: 'mia', - languageCode: 'ru' + languageCode: 'ru', + householdDefaultLocale: household.defaultLocale } ], approvePendingHouseholdMember: async (input) => @@ -83,6 +88,24 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdId: household.householdId, telegramUserId: '555777', displayName: 'Mia', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + isAdmin: false + } + : null, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + ...household, + defaultLocale: locale + }), + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => + telegramUserId === '555777' + ? { + id: 'member-555777', + householdId: household.householdId, + telegramUserId, + displayName: 'Mia', + preferredLocale: locale, + householdDefaultLocale: household.defaultLocale, isAdmin: false } : null @@ -99,6 +122,8 @@ describe('createMiniAppPendingMembersHandler', () => { householdId: 'household-1', telegramUserId: '123456', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true } ] @@ -141,7 +166,8 @@ describe('createMiniAppPendingMembersHandler', () => { telegramUserId: '555777', displayName: 'Mia', username: 'mia', - languageCode: 'ru' + languageCode: 'ru', + householdDefaultLocale: 'ru' } ] }) @@ -158,6 +184,8 @@ describe('createMiniAppApproveMemberHandler', () => { householdId: 'household-1', telegramUserId: '123456', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true } ] @@ -199,6 +227,8 @@ describe('createMiniAppApproveMemberHandler', () => { householdId: 'household-1', telegramUserId: '555777', displayName: 'Mia', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: false } }) diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 34c88e7..4ab567f 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -15,7 +15,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdName: 'Kojori House', telegramChatId: '-100123', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' as const } let joinToken: string | null = 'join-token' const members = new Map< @@ -25,6 +26,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdId: string telegramUserId: string displayName: string + preferredLocale: 'en' | 'ru' | null + householdDefaultLocale: 'en' | 'ru' isAdmin: boolean } >() @@ -35,6 +38,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { displayName: string username: string | null languageCode: string | null + householdDefaultLocale: 'ru' } | null = null return { @@ -77,7 +81,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale } return pending }, @@ -89,6 +94,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdId: household.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true } members.set(input.telegramUserId, member) @@ -112,11 +119,26 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdId: household.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true } members.set(pending.telegramUserId, member) pending = null return member + }, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + ...household, + defaultLocale: locale + }), + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => { + const member = members.get(telegramUserId) + return member + ? { + ...member, + preferredLocale: locale + } + : null } } } @@ -164,7 +186,9 @@ describe('createMiniAppAuthHandler', () => { authorized: true, member: { displayName: 'Stan', - isAdmin: true + isAdmin: true, + preferredLocale: null, + householdDefaultLocale: 'ru' }, telegramUser: { id: '123456', @@ -208,7 +232,8 @@ describe('createMiniAppAuthHandler', () => { authorized: false, onboarding: { status: 'join_required', - householdName: 'Kojori House' + householdName: 'Kojori House', + householdDefaultLocale: 'ru' } }) }) @@ -246,7 +271,8 @@ describe('createMiniAppAuthHandler', () => { authorized: false, onboarding: { status: 'pending', - householdName: 'Kojori House' + householdName: 'Kojori House', + householdDefaultLocale: 'ru' } }) }) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index ef8e80c..e11002e 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -1,4 +1,5 @@ import type { HouseholdOnboardingService } from '@household/application' +import type { SupportedLocale } from '@household/domain' import type { Logger } from '@household/observability' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' @@ -101,11 +102,14 @@ export interface MiniAppSessionResult { householdId: string displayName: string isAdmin: boolean + preferredLocale: SupportedLocale | null + householdDefaultLocale: SupportedLocale } telegramUser?: ReturnType onboarding?: { status: 'join_required' | 'pending' | 'open_from_group' householdName?: string + householdDefaultLocale?: SupportedLocale } } @@ -154,7 +158,8 @@ export function createMiniAppSessionService(options: { telegramUser, onboarding: { status: 'pending', - householdName: access.household.name + householdName: access.household.name, + householdDefaultLocale: access.household.defaultLocale } } case 'join_required': @@ -163,7 +168,8 @@ export function createMiniAppSessionService(options: { telegramUser, onboarding: { status: 'join_required', - householdName: access.household.name + householdName: access.household.name, + householdDefaultLocale: access.household.defaultLocale } } case 'open_from_group': @@ -334,7 +340,8 @@ export function createMiniAppJoinHandler(options: { authorized: false, onboarding: { status: 'pending', - householdName: result.household.name + householdName: result.household.name, + householdDefaultLocale: result.household.defaultLocale }, telegramUser }, diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index f5658e5..08503c4 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -76,7 +76,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdName: 'Kojori House', telegramChatId: '-100123', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' as const } return { @@ -110,7 +111,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale }), getPendingHouseholdMember: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null, @@ -119,13 +121,20 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdId: household.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, listHouseholdMembers: async () => [], listHouseholdMembersByTelegramUserId: async () => [], listPendingHouseholdMembers: async () => [], - approvePendingHouseholdMember: async () => null + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + ...household, + defaultLocale: locale + }), + updateMemberPreferredLocale: async () => null } } @@ -147,6 +156,8 @@ describe('createMiniAppDashboardHandler', () => { householdId: 'household-1', telegramUserId: '123456', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true } ] @@ -223,6 +234,8 @@ describe('createMiniAppDashboardHandler', () => { householdId: 'household-1', telegramUserId: '123456', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true } ] diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts new file mode 100644 index 0000000..2710725 --- /dev/null +++ b/apps/bot/src/miniapp-locale.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from 'bun:test' + +import { + createHouseholdOnboardingService, + createLocalePreferenceService +} from '@household/application' +import type { + HouseholdConfigurationRepository, + HouseholdMemberRecord, + HouseholdTopicBindingRecord +} from '@household/ports' + +import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' + +function repository(): HouseholdConfigurationRepository { + const household = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: 'ru' as 'en' | 'ru' + } + + const members = new Map([ + [ + '123456', + { + id: 'member-123456', + householdId: household.householdId, + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + isAdmin: true + } + ], + [ + '222222', + { + id: 'member-222222', + householdId: household.householdId, + telegramUserId: '222222', + displayName: 'Mia', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + isAdmin: false + } + ] + ]) + + return { + registerTelegramHouseholdChat: async () => ({ status: 'existing', household }), + getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, + bindHouseholdTopic: async (input) => + ({ + householdId: input.householdId, + role: input.role, + telegramThreadId: input.telegramThreadId, + topicName: input.topicName?.trim() || null + }) satisfies HouseholdTopicBindingRecord, + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + upsertHouseholdJoinToken: async () => ({ + householdId: household.householdId, + householdName: household.householdName, + token: 'join-token', + createdByTelegramUserId: null + }), + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async (input) => ({ + householdId: input.householdId, + householdName: household.householdName, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + username: input.username?.trim() || null, + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale + }), + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async (input) => + members.get(input.telegramUserId) ?? { + id: `member-${input.telegramUserId}`, + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, + isAdmin: input.isAdmin === true + }, + getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null, + listHouseholdMembers: async () => [...members.values()], + listHouseholdMembersByTelegramUserId: async (telegramUserId) => { + const member = members.get(telegramUserId) + return member ? [member] : [] + }, + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async (_householdId, locale) => { + household.defaultLocale = locale + for (const [id, member] of members.entries()) { + members.set(id, { + ...member, + householdDefaultLocale: locale + }) + } + return household + }, + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => { + const member = members.get(telegramUserId) + if (!member) { + return null + } + + const next = { + ...member, + preferredLocale: locale + } + members.set(telegramUserId, next) + return next + } + } +} + +describe('createMiniAppLocalePreferenceHandler', () => { + test('updates member locale preference', async () => { + const authDate = Math.floor(Date.now() / 1000) + const householdRepository = repository() + const handler = createMiniAppLocalePreferenceHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository: householdRepository + }), + localePreferenceService: createLocalePreferenceService(householdRepository) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/preferences/locale', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + language_code: 'ru' + }), + locale: 'en', + scope: 'member' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + locale: { + scope: 'member', + effectiveLocale: 'en', + memberPreferredLocale: 'en', + householdDefaultLocale: 'ru' + } + }) + }) + + test('rejects household locale updates for non-admin members', async () => { + const authDate = Math.floor(Date.now() / 1000) + const householdRepository = repository() + const handler = createMiniAppLocalePreferenceHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository: householdRepository + }), + localePreferenceService: createLocalePreferenceService(householdRepository) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/preferences/locale', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 222222, + first_name: 'Mia', + language_code: 'ru' + }), + locale: 'en', + scope: 'household' + }) + }) + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + ok: false, + error: 'Admin access required' + }) + }) +}) diff --git a/apps/bot/src/miniapp-locale.ts b/apps/bot/src/miniapp-locale.ts new file mode 100644 index 0000000..725ce96 --- /dev/null +++ b/apps/bot/src/miniapp-locale.ts @@ -0,0 +1,150 @@ +import type { HouseholdOnboardingService, LocalePreferenceService } from '@household/application' +import { normalizeSupportedLocale } from '@household/domain' +import type { Logger } from '@household/observability' + +import { + allowedMiniAppOrigin, + createMiniAppSessionService, + miniAppErrorResponse, + miniAppJsonResponse +} from './miniapp-auth' + +interface LocalePreferenceRequest { + initData: string + locale: 'en' | 'ru' + scope: 'member' | 'household' +} + +async function readLocalePreferenceRequest(request: Request): Promise { + const text = await request.text() + if (text.trim().length === 0) { + throw new Error('Missing initData') + } + + let parsed: { initData?: string; locale?: string; scope?: string } + try { + parsed = JSON.parse(text) as { initData?: string; locale?: string; scope?: string } + } catch { + throw new Error('Invalid JSON body') + } + + const initData = parsed.initData?.trim() + if (!initData) { + throw new Error('Missing initData') + } + + const locale = normalizeSupportedLocale(parsed.locale) + if (!locale) { + throw new Error('Invalid locale') + } + + const scope = parsed.scope?.trim() + if (scope !== 'member' && scope !== 'household') { + throw new Error('Invalid locale scope') + } + + return { + initData, + locale, + scope + } +} + +export function createMiniAppLocalePreferenceHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + localePreferenceService: LocalePreferenceService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const payload = await readLocalePreferenceRequest(request) + const session = await sessionService.authenticate({ + initData: payload.initData + }) + + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) + } + + if (!session.authorized || !session.member || !session.telegramUser) { + return miniAppJsonResponse( + { ok: false, error: 'Access limited to active household members' }, + 403, + origin + ) + } + + let memberPreferredLocale = session.member.preferredLocale + let householdDefaultLocale = session.member.householdDefaultLocale + + if (payload.scope === 'member') { + const result = await options.localePreferenceService.updateMemberLocale({ + householdId: session.member.householdId, + telegramUserId: session.telegramUser.id, + locale: payload.locale + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse({ ok: false, error: 'Member not found' }, 404, origin) + } + + memberPreferredLocale = result.member.preferredLocale + householdDefaultLocale = result.member.householdDefaultLocale + } else { + const result = await options.localePreferenceService.updateHouseholdLocale({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + locale: payload.locale + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse({ ok: false, error: 'Admin access required' }, 403, origin) + } + + householdDefaultLocale = result.household.defaultLocale + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + locale: { + scope: payload.scope, + effectiveLocale: memberPreferredLocale ?? householdDefaultLocale, + memberPreferredLocale, + householdDefaultLocale + } + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index c47fce0..9d97a79 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -9,7 +9,7 @@ import type { } from '@household/ports' import { createDbClient, schema } from '@household/db' -import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' +import { getBotTranslations, type BotLocale } from './i18n' export interface PurchaseTopicIngestionConfig { householdId: string @@ -325,7 +325,7 @@ export function registerPurchaseTopicIngestion( try { const status = await repository.save(record, options.llmFallback) - const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx)) + const acknowledgement = buildPurchaseAcknowledgement(status, 'en') if (status.status === 'created') { options.logger?.info( @@ -395,7 +395,13 @@ export function registerConfiguredPurchaseTopicIngestion( try { const status = await repository.save(record, options.llmFallback) - const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx)) + const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId( + record.householdId + ) + const acknowledgement = buildPurchaseAcknowledgement( + status, + householdChat?.defaultLocale ?? 'en' + ) if (status.status === 'created') { options.logger?.info( diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 023cf00..47dd917 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -32,6 +32,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppLocalePreference?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -69,6 +75,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' const miniAppApproveMemberPath = options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member' + const miniAppLocalePreferencePath = + options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -101,6 +109,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppApproveMember.handler(request) } + if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) { + return await options.miniAppLocalePreference.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index e33b620..2e930ae 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -7,6 +7,7 @@ import { fetchMiniAppPendingMembers, fetchMiniAppSession, joinMiniAppHousehold, + updateMiniAppLocalePreference, type MiniAppDashboard, type MiniAppPendingMember } from './miniapp-api' @@ -36,6 +37,8 @@ type SessionState = member: { displayName: string isAdmin: boolean + preferredLocale: Locale | null + householdDefaultLocale: Locale } telegramUser: { firstName: string | null @@ -51,7 +54,9 @@ const demoSession: Extract = { mode: 'demo', member: { displayName: 'Demo Resident', - isAdmin: false + isAdmin: false, + preferredLocale: 'en', + householdDefaultLocale: 'en' }, telegramUser: { firstName: 'Demo', @@ -112,6 +117,8 @@ function App() { const [pendingMembers, setPendingMembers] = createSignal([]) const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) + const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) + const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const copy = createMemo(() => dictionary[locale()]) const onboardingSession = createMemo(() => { @@ -153,7 +160,8 @@ function App() { } async function bootstrap() { - setLocale(detectLocale()) + const fallbackLocale = detectLocale() + setLocale(fallbackLocale) webApp?.ready?.() webApp?.expand?.() @@ -175,6 +183,10 @@ function App() { try { const payload = await fetchMiniAppSession(initData, joinContext().joinToken) if (!payload.authorized || !payload.member || !payload.telegramUser) { + setLocale( + payload.onboarding?.householdDefaultLocale ?? + ((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en') + ) setSession({ status: 'onboarding', mode: payload.onboarding?.status ?? 'open_from_group', @@ -192,6 +204,7 @@ function App() { return } + setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale) setSession({ status: 'ready', mode: 'live', @@ -284,6 +297,7 @@ function App() { try { const payload = await joinMiniAppHousehold(initData, joinToken) if (payload.authorized && payload.member && payload.telegramUser) { + setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale) setSession({ status: 'ready', mode: 'live', @@ -297,6 +311,10 @@ function App() { return } + setLocale( + payload.onboarding?.householdDefaultLocale ?? + ((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en') + ) setSession({ status: 'onboarding', mode: payload.onboarding?.status ?? 'pending', @@ -339,6 +357,71 @@ function App() { } } + async function handleMemberLocaleChange(nextLocale: Locale) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + + setLocale(nextLocale) + + if (!initData || currentReady?.mode !== 'live') { + return + } + + setSavingMemberLocale(true) + + try { + const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'member') + + setSession((current) => + current.status === 'ready' + ? { + ...current, + member: { + ...current.member, + preferredLocale: updated.memberPreferredLocale, + householdDefaultLocale: updated.householdDefaultLocale + } + } + : current + ) + setLocale(updated.effectiveLocale) + } finally { + setSavingMemberLocale(false) + } + } + + async function handleHouseholdLocaleChange(nextLocale: Locale) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setSavingHouseholdLocale(true) + + try { + const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'household') + + setSession((current) => + current.status === 'ready' + ? { + ...current, + member: { + ...current.member, + householdDefaultLocale: updated.householdDefaultLocale + } + } + : current + ) + + if (!currentReady.member.preferredLocale) { + setLocale(updated.effectiveLocale) + } + } finally { + setSavingHouseholdLocale(false) + } + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -396,6 +479,34 @@ function App() { case 'house': return readySession()?.member.isAdmin ? (
+
+
+ {copy().householdLanguage} + {readySession()?.member.householdDefaultLocale.toUpperCase()} +
+
+ + +
+
{copy().pendingMembersTitle} @@ -470,14 +581,16 @@ function App() { diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 7698ce9..39d4488 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -27,6 +27,8 @@ export const dictionary = { 'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.', reload: 'Retry', language: 'Language', + householdLanguage: 'Household language', + savingLanguage: 'Saving…', home: 'Home', balances: 'Balances', ledger: 'Ledger', @@ -90,6 +92,8 @@ export const dictionary = { 'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.', reload: 'Повторить', language: 'Язык', + householdLanguage: 'Язык дома', + savingLanguage: 'Сохраняем…', home: 'Главная', balances: 'Баланс', ledger: 'Леджер', diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index e553fce..3bd21b5 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -5,6 +5,8 @@ export interface MiniAppSession { member?: { displayName: string isAdmin: boolean + preferredLocale: 'en' | 'ru' | null + householdDefaultLocale: 'en' | 'ru' } telegramUser?: { firstName: string | null @@ -14,9 +16,17 @@ export interface MiniAppSession { onboarding?: { status: 'join_required' | 'pending' | 'open_from_group' householdName?: string + householdDefaultLocale?: 'en' | 'ru' } } +export interface MiniAppLocalePreference { + scope: 'member' | 'household' + effectiveLocale: 'en' | 'ru' + memberPreferredLocale: 'en' | 'ru' | null + householdDefaultLocale: 'en' | 'ru' +} + export interface MiniAppPendingMember { telegramUserId: string displayName: string @@ -219,3 +229,34 @@ export async function approveMiniAppPendingMember( throw new Error(payload.error ?? 'Failed to approve member') } } + +export async function updateMiniAppLocalePreference( + initData: string, + locale: 'en' | 'ru', + scope: 'member' | 'household' +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/preferences/locale`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + locale, + scope + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + locale?: MiniAppLocalePreference + error?: string + } + + if (!response.ok || !payload.authorized || !payload.locale) { + throw new Error(payload.error ?? 'Failed to update locale preference') + } + + return payload.locale +}