feat(bot): persist locale preferences across mini app and replies

This commit is contained in:
2026-03-09 13:17:46 +04:00
parent 9de6bcc31b
commit 2d8e0491cc
19 changed files with 904 additions and 77 deletions

View File

@@ -95,7 +95,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
householdName: 'Kojori House', householdName: 'Kojori House',
telegramChatId: '-100222333', telegramChatId: '-100222333',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori House' title: 'Kojori House',
defaultLocale: 'ru'
} }
}), }),
getTelegramHouseholdChat: async () => ({ getTelegramHouseholdChat: async () => ({
@@ -103,14 +104,16 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
householdName: 'Kojori House', householdName: 'Kojori House',
telegramChatId: '-100222333', telegramChatId: '-100222333',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori House' title: 'Kojori House',
defaultLocale: 'ru'
}), }),
getHouseholdChatByHouseholdId: async () => ({ getHouseholdChatByHouseholdId: async () => ({
householdId: 'household-1', householdId: 'household-1',
householdName: 'Kojori House', householdName: 'Kojori House',
telegramChatId: '-100222333', telegramChatId: '-100222333',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori House' title: 'Kojori House',
defaultLocale: 'ru'
}), }),
bindHouseholdTopic: async (input) => ({ bindHouseholdTopic: async (input) => ({
householdId: input.householdId, householdId: input.householdId,
@@ -143,7 +146,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
username: input.username?.trim() || null, username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null languageCode: input.languageCode?.trim() || null,
householdDefaultLocale: 'ru'
}), }),
getPendingHouseholdMember: async () => null, getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null,
@@ -152,6 +156,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
householdId: input.householdId, householdId: input.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: 'ru',
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
@@ -162,11 +168,30 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
householdId: 'household-1', householdId: 'household-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: false isAdmin: false
} }
], ],
listPendingHouseholdMembers: async () => [], 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({ expect(calls[0]?.payload).toMatchObject({
chat_id: '-100222333', chat_id: '-100222333',
message_thread_id: 77, 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({ expect(calls[1]?.payload).toMatchObject({
text: 'Anonymous feedback delivered.' text: 'Анонимное сообщение отправлено.'
}) })
}) })
@@ -303,7 +328,7 @@ describe('registerAnonymousFeedback', () => {
expect(calls).toHaveLength(1) expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({ 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(submit).toHaveBeenCalledTimes(1)
expect(calls[0]?.payload).toMatchObject({ expect(calls[0]?.payload).toMatchObject({
text: 'Send me the anonymous message in your next reply, or tap Cancel.' text: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».'
}) })
expect(calls[1]?.payload).toMatchObject({ expect(calls[1]?.payload).toMatchObject({
chat_id: '-100222333', chat_id: '-100222333',
message_thread_id: 77, 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({ expect(calls[2]?.payload).toMatchObject({
text: 'Anonymous feedback delivered.' text: 'Анонимное сообщение отправлено.'
}) })
}) })
@@ -620,7 +645,7 @@ describe('registerAnonymousFeedback', () => {
expect(calls).toHaveLength(1) expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({ expect(calls[0]?.payload).toMatchObject({
text: 'Anonymous feedback cooldown is active. You can send the next message in 6 hours.' text: 'Сейчас действует пауза на анонимные сообщения. Следующее сообщение можно отправить через 6 часов.'
}) })
}) })
}) })

View File

@@ -7,7 +7,8 @@ import type {
} from '@household/ports' } from '@household/ports'
import type { Bot, Context } from 'grammy' 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 ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback' const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback'
@@ -114,9 +115,13 @@ async function clearPendingAnonymousFeedbackPrompt(
async function startPendingAnonymousFeedbackPrompt( async function startPendingAnonymousFeedbackPrompt(
repository: TelegramPendingActionRepository, repository: TelegramPendingActionRepository,
householdConfigurationRepository: HouseholdConfigurationRepository,
ctx: Context ctx: Context
): Promise<void> { ): Promise<void> {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback const t = getBotTranslations(locale).anonymousFeedback
const telegramUserId = ctx.from?.id?.toString() const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.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 telegramMessageId = options.ctx.msg?.message_id?.toString()
const telegramUpdateId = const telegramUpdateId =
'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined 'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined
const locale = botLocaleFromContext(options.ctx) const fallbackLocale = await resolveReplyLocale({
const t = getBotTranslations(locale).anonymousFeedback ctx: options.ctx,
repository: options.householdConfigurationRepository
})
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) { if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
await options.ctx.reply(t.unableToIdentifyMessage) await options.ctx.reply(
getBotTranslations(fallbackLocale).anonymousFeedback.unableToIdentifyMessage
)
return return
} }
@@ -167,17 +176,19 @@ async function submitAnonymousFeedback(options: {
if (memberships.length === 0) { if (memberships.length === 0) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(t.notMember) await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.notMember)
return return
} }
if (memberships.length > 1) { if (memberships.length > 1) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(t.multipleHouseholds) await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.multipleHouseholds)
return return
} }
const member = memberships[0]! const member = memberships[0]!
const locale = member.preferredLocale ?? member.householdDefaultLocale
const t = getBotTranslations(locale).anonymousFeedback
const householdChat = const householdChat =
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId) await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding( const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
@@ -273,7 +284,10 @@ export function registerAnonymousFeedback(options: {
logger?: Logger logger?: Logger
}): void { }): void {
options.bot.command('cancel', async (ctx) => { options.bot.command('cancel', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback const t = getBotTranslations(locale).anonymousFeedback
if (!isPrivateChat(ctx)) { if (!isPrivateChat(ctx)) {
return return
@@ -297,7 +311,10 @@ export function registerAnonymousFeedback(options: {
}) })
options.bot.command('anon', async (ctx) => { options.bot.command('anon', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback const t = getBotTranslations(locale).anonymousFeedback
if (!isPrivateChat(ctx)) { if (!isPrivateChat(ctx)) {
await ctx.reply(t.useInPrivateChat) await ctx.reply(t.useInPrivateChat)
@@ -306,7 +323,11 @@ export function registerAnonymousFeedback(options: {
const rawText = commandArgText(ctx) const rawText = commandArgText(ctx)
if (rawText.length === 0) { if (rawText.length === 0) {
await startPendingAnonymousFeedbackPrompt(options.promptRepository, ctx) await startPendingAnonymousFeedbackPrompt(
options.promptRepository,
options.householdConfigurationRepository,
ctx
)
return return
} }
@@ -351,7 +372,10 @@ export function registerAnonymousFeedback(options: {
}) })
options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => { 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 const t = getBotTranslations(locale).anonymousFeedback
if (!isPrivateChat(ctx)) { if (!isPrivateChat(ctx)) {
await ctx.answerCallbackQuery({ await ctx.answerCallbackQuery({

View File

@@ -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<Context, 'chat' | 'from'>
repository: HouseholdConfigurationRepository | undefined
householdId?: string
}): Promise<BotLocale> {
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
}

View File

@@ -1,19 +1,31 @@
import { Bot } from 'grammy' import { Bot } from 'grammy'
import type { Logger } from '@household/observability' 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' 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) const bot = new Bot(token)
bot.command('help', async (ctx) => { bot.command('help', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: householdConfigurationRepository
})
await ctx.reply(formatTelegramHelpText(locale)) await ctx.reply(formatTelegramHelpText(locale))
}) })
bot.command('household_status', async (ctx) => { bot.command('household_status', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: householdConfigurationRepository
})
await ctx.reply(getBotTranslations(locale).bot.householdStatusPending) await ctx.reply(getBotTranslations(locale).bot.householdStatusPending)
}) })

View File

@@ -2,7 +2,8 @@ import type { FinanceCommandService } from '@household/application'
import type { HouseholdConfigurationRepository } from '@household/ports' import type { HouseholdConfigurationRepository } from '@household/ports'
import type { Bot, Context } from 'grammy' 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[] { function commandArgs(ctx: Context): string[] {
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : '' const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
@@ -24,10 +25,10 @@ export function createFinanceCommandsService(options: {
register: (bot: Bot) => void register: (bot: Bot) => void
} { } {
function formatStatement( function formatStatement(
ctx: Context, locale: Parameters<typeof getBotTranslations>[0],
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>> dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
): string { ): string {
const t = getBotTranslations(botLocaleFromContext(ctx)).finance const t = getBotTranslations(locale).finance
return [ return [
t.statementTitle(dashboard.period), t.statementTitle(dashboard.period),
@@ -42,7 +43,11 @@ export function createFinanceCommandsService(options: {
service: FinanceCommandService service: FinanceCommandService
householdId: string householdId: string
} | null> { } | null> {
const t = getBotTranslations(botLocaleFromContext(ctx)).finance const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).finance
if (!isGroupChat(ctx)) { if (!isGroupChat(ctx)) {
await ctx.reply(t.useInGroup) await ctx.reply(t.useInGroup)
return null return null
@@ -63,7 +68,11 @@ export function createFinanceCommandsService(options: {
} }
async function requireMember(ctx: Context) { 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() const telegramUserId = ctx.from?.id?.toString()
if (!telegramUserId) { if (!telegramUserId) {
await ctx.reply(t.unableToIdentifySender) await ctx.reply(t.unableToIdentifySender)
@@ -89,7 +98,11 @@ export function createFinanceCommandsService(options: {
} }
async function requireAdmin(ctx: Context) { 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) const resolved = await requireMember(ctx)
if (!resolved) { if (!resolved) {
return null return null
@@ -105,7 +118,11 @@ export function createFinanceCommandsService(options: {
function register(bot: Bot): void { function register(bot: Bot): void {
bot.command('cycle_open', async (ctx) => { 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) const resolved = await requireAdmin(ctx)
if (!resolved) { if (!resolved) {
return return
@@ -126,7 +143,11 @@ export function createFinanceCommandsService(options: {
}) })
bot.command('cycle_close', async (ctx) => { 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) const resolved = await requireAdmin(ctx)
if (!resolved) { if (!resolved) {
return return
@@ -146,7 +167,11 @@ export function createFinanceCommandsService(options: {
}) })
bot.command('rent_set', async (ctx) => { 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) const resolved = await requireAdmin(ctx)
if (!resolved) { if (!resolved) {
return return
@@ -172,7 +197,11 @@ export function createFinanceCommandsService(options: {
}) })
bot.command('utility_add', async (ctx) => { 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) const resolved = await requireAdmin(ctx)
if (!resolved) { if (!resolved) {
return return
@@ -205,7 +234,11 @@ export function createFinanceCommandsService(options: {
}) })
bot.command('statement', async (ctx) => { 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) const resolved = await requireMember(ctx)
if (!resolved) { if (!resolved) {
return return
@@ -218,7 +251,7 @@ export function createFinanceCommandsService(options: {
return return
} }
await ctx.reply(formatStatement(ctx, dashboard)) await ctx.reply(formatStatement(locale, dashboard))
} catch (error) { } catch (error) {
await ctx.reply(t.statementFailed((error as Error).message)) await ctx.reply(t.statementFailed((error as Error).message))
} }

View File

@@ -147,7 +147,8 @@ describe('registerHouseholdSetupCommands', () => {
status: 'pending', status: 'pending',
household: { household: {
id: 'household-1', id: 'household-1',
name: 'Kojori House' name: 'Kojori House',
defaultLocale: 'ru'
} }
} }
} }
@@ -236,7 +237,8 @@ describe('registerHouseholdSetupCommands', () => {
status: 'pending', status: 'pending',
household: { household: {
id: 'household-1', id: 'household-1',
name: 'Kojori House' name: 'Kojori House',
defaultLocale: 'ru'
} }
} }
} }

View File

@@ -1,12 +1,15 @@
import type { import type {
HouseholdAdminService, HouseholdAdminService,
HouseholdOnboardingService, HouseholdOnboardingService,
HouseholdSetupService HouseholdSetupService,
HouseholdMiniAppAccess
} from '@household/application' } from '@household/application'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { HouseholdConfigurationRepository } from '@household/ports'
import type { Bot, Context } from 'grammy' 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:' const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
@@ -175,12 +178,17 @@ export function registerHouseholdSetupCommands(options: {
householdSetupService: HouseholdSetupService householdSetupService: HouseholdSetupService
householdOnboardingService: HouseholdOnboardingService householdOnboardingService: HouseholdOnboardingService
householdAdminService: HouseholdAdminService householdAdminService: HouseholdAdminService
householdConfigurationRepository?: HouseholdConfigurationRepository
miniAppUrl?: string miniAppUrl?: string
logger?: Logger logger?: Logger
}): void { }): void {
options.bot.command('start', async (ctx) => { options.bot.command('start', async (ctx) => {
const locale = botLocaleFromContext(ctx) const fallbackLocale = await resolveReplyLocale({
const t = getBotTranslations(locale) ctx,
repository: options.householdConfigurationRepository
})
let locale = fallbackLocale
let t = getBotTranslations(locale)
if (ctx.chat?.type !== 'private') { if (ctx.chat?.type !== 'private') {
return return
@@ -231,6 +239,18 @@ export function registerHouseholdSetupCommands(options: {
return 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') { if (result.status === 'active') {
await ctx.reply( await ctx.reply(
t.setup.alreadyActiveMember(result.member.displayName), t.setup.alreadyActiveMember(result.member.displayName),
@@ -246,8 +266,12 @@ export function registerHouseholdSetupCommands(options: {
}) })
options.bot.command('setup', async (ctx) => { options.bot.command('setup', async (ctx) => {
const locale = botLocaleFromContext(ctx) const fallbackLocale = await resolveReplyLocale({
const t = getBotTranslations(locale) ctx,
repository: options.householdConfigurationRepository
})
let locale = fallbackLocale
let t = getBotTranslations(locale)
if (!isGroupChat(ctx)) { if (!isGroupChat(ctx)) {
await ctx.reply(t.setup.useSetupInGroup) await ctx.reply(t.setup.useSetupInGroup)
@@ -278,6 +302,9 @@ export function registerHouseholdSetupCommands(options: {
return return
} }
locale = result.household.defaultLocale
t = getBotTranslations(locale)
options.logger?.info( options.logger?.info(
{ {
event: 'household_setup.chat_registered', event: 'household_setup.chat_registered',
@@ -326,7 +353,10 @@ export function registerHouseholdSetupCommands(options: {
}) })
options.bot.command('bind_purchase_topic', async (ctx) => { options.bot.command('bind_purchase_topic', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale) const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) { if (!isGroupChat(ctx)) {
@@ -373,7 +403,10 @@ export function registerHouseholdSetupCommands(options: {
}) })
options.bot.command('bind_feedback_topic', async (ctx) => { options.bot.command('bind_feedback_topic', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale) const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) { if (!isGroupChat(ctx)) {
@@ -420,7 +453,10 @@ export function registerHouseholdSetupCommands(options: {
}) })
options.bot.command('pending_members', async (ctx) => { options.bot.command('pending_members', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale) const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) { if (!isGroupChat(ctx)) {
@@ -456,7 +492,10 @@ export function registerHouseholdSetupCommands(options: {
}) })
options.bot.command('approve_member', async (ctx) => { options.bot.command('approve_member', async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale) const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) { if (!isGroupChat(ctx)) {
@@ -493,7 +532,10 @@ export function registerHouseholdSetupCommands(options: {
options.bot.callbackQuery( options.bot.callbackQuery(
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`), new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
async (ctx) => { async (ctx) => {
const locale = botLocaleFromContext(ctx) const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale) const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) { 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
}
}

View File

@@ -5,6 +5,7 @@ import {
createHouseholdAdminService, createHouseholdAdminService,
createFinanceCommandService, createFinanceCommandService,
createHouseholdOnboardingService, createHouseholdOnboardingService,
createLocalePreferenceService,
createMiniAppAdminService, createMiniAppAdminService,
createHouseholdSetupService, createHouseholdSetupService,
createReminderJobService createReminderJobService
@@ -37,6 +38,7 @@ import {
createMiniAppApproveMemberHandler, createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler createMiniAppPendingMembersHandler
} from './miniapp-admin' } from './miniapp-admin'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
const runtime = getBotRuntimeConfig() const runtime = getBotRuntimeConfig()
configureLogger({ configureLogger({
@@ -45,13 +47,16 @@ configureLogger({
}) })
const logger = getLogger('runtime') const logger = getLogger('runtime')
const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram'))
const webhookHandler = webhookCallback(bot, 'std/http')
const shutdownTasks: Array<() => Promise<void>> = [] const shutdownTasks: Array<() => Promise<void>> = []
const householdConfigurationRepositoryClient = runtime.databaseUrl const householdConfigurationRepositoryClient = runtime.databaseUrl
? createDbHouseholdConfigurationRepository(runtime.databaseUrl) ? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
: null : null
const bot = createTelegramBot(
runtime.telegramBotToken,
getLogger('telegram'),
householdConfigurationRepositoryClient?.repository
)
const webhookHandler = webhookCallback(bot, 'std/http')
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>() const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>() const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
const householdOnboardingService = householdConfigurationRepositoryClient const householdOnboardingService = householdConfigurationRepositoryClient
@@ -62,6 +67,9 @@ const householdOnboardingService = householdConfigurationRepositoryClient
const miniAppAdminService = householdConfigurationRepositoryClient const miniAppAdminService = householdConfigurationRepositoryClient
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository) ? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
: null : null
const localePreferenceService = householdConfigurationRepositoryClient
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
: null
const telegramPendingActionRepositoryClient = const telegramPendingActionRepositoryClient =
runtime.databaseUrl && runtime.anonymousFeedbackEnabled runtime.databaseUrl && runtime.anonymousFeedbackEnabled
? createDbTelegramPendingActionRepository(runtime.databaseUrl!) ? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
@@ -168,6 +176,7 @@ if (householdConfigurationRepositoryClient) {
householdConfigurationRepositoryClient.repository householdConfigurationRepositoryClient.repository
), ),
householdOnboardingService: householdOnboardingService!, householdOnboardingService: householdOnboardingService!,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
...(runtime.miniAppAllowedOrigins[0] ...(runtime.miniAppAllowedOrigins[0]
? { ? {
miniAppUrl: runtime.miniAppAllowedOrigins[0] miniAppUrl: runtime.miniAppAllowedOrigins[0]
@@ -279,6 +288,15 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-admin') logger: getLogger('miniapp-admin')
}) })
: undefined, : undefined,
miniAppLocalePreference: householdOnboardingService
? createMiniAppLocalePreferenceHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
localePreferenceService: localePreferenceService!,
logger: getLogger('miniapp-admin')
})
: undefined,
scheduler: scheduler:
reminderJobs && runtime.schedulerSharedSecret reminderJobs && runtime.schedulerSharedSecret
? { ? {

View File

@@ -18,7 +18,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdName: 'Kojori House', householdName: 'Kojori House',
telegramChatId: '-100123', telegramChatId: '-100123',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori House' title: 'Kojori House',
defaultLocale: 'ru' as const
} }
return { return {
@@ -52,7 +53,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
username: input.username?.trim() || null, username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null languageCode: input.languageCode?.trim() || null,
householdDefaultLocale: household.defaultLocale
}), }),
getPendingHouseholdMember: async () => null, getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null,
@@ -61,6 +63,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdId: household.householdId, householdId: household.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
@@ -73,7 +77,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
telegramUserId: '555777', telegramUserId: '555777',
displayName: 'Mia', displayName: 'Mia',
username: 'mia', username: 'mia',
languageCode: 'ru' languageCode: 'ru',
householdDefaultLocale: household.defaultLocale
} }
], ],
approvePendingHouseholdMember: async (input) => approvePendingHouseholdMember: async (input) =>
@@ -83,6 +88,24 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdId: household.householdId, householdId: household.householdId,
telegramUserId: '555777', telegramUserId: '555777',
displayName: 'Mia', 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 isAdmin: false
} }
: null : null
@@ -99,6 +122,8 @@ describe('createMiniAppPendingMembersHandler', () => {
householdId: 'household-1', householdId: 'household-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: true isAdmin: true
} }
] ]
@@ -141,7 +166,8 @@ describe('createMiniAppPendingMembersHandler', () => {
telegramUserId: '555777', telegramUserId: '555777',
displayName: 'Mia', displayName: 'Mia',
username: 'mia', username: 'mia',
languageCode: 'ru' languageCode: 'ru',
householdDefaultLocale: 'ru'
} }
] ]
}) })
@@ -158,6 +184,8 @@ describe('createMiniAppApproveMemberHandler', () => {
householdId: 'household-1', householdId: 'household-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: true isAdmin: true
} }
] ]
@@ -199,6 +227,8 @@ describe('createMiniAppApproveMemberHandler', () => {
householdId: 'household-1', householdId: 'household-1',
telegramUserId: '555777', telegramUserId: '555777',
displayName: 'Mia', displayName: 'Mia',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: false isAdmin: false
} }
}) })

View File

@@ -15,7 +15,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdName: 'Kojori House', householdName: 'Kojori House',
telegramChatId: '-100123', telegramChatId: '-100123',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori House' title: 'Kojori House',
defaultLocale: 'ru' as const
} }
let joinToken: string | null = 'join-token' let joinToken: string | null = 'join-token'
const members = new Map< const members = new Map<
@@ -25,6 +26,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdId: string householdId: string
telegramUserId: string telegramUserId: string
displayName: string displayName: string
preferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru'
isAdmin: boolean isAdmin: boolean
} }
>() >()
@@ -35,6 +38,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
displayName: string displayName: string
username: string | null username: string | null
languageCode: string | null languageCode: string | null
householdDefaultLocale: 'ru'
} | null = null } | null = null
return { return {
@@ -77,7 +81,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
username: input.username?.trim() || null, username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null languageCode: input.languageCode?.trim() || null,
householdDefaultLocale: household.defaultLocale
} }
return pending return pending
}, },
@@ -89,6 +94,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdId: household.householdId, householdId: household.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(input.telegramUserId, member) members.set(input.telegramUserId, member)
@@ -112,11 +119,26 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdId: household.householdId, householdId: household.householdId,
telegramUserId: pending.telegramUserId, telegramUserId: pending.telegramUserId,
displayName: pending.displayName, displayName: pending.displayName,
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(pending.telegramUserId, member) members.set(pending.telegramUserId, member)
pending = null pending = null
return member 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, authorized: true,
member: { member: {
displayName: 'Stan', displayName: 'Stan',
isAdmin: true isAdmin: true,
preferredLocale: null,
householdDefaultLocale: 'ru'
}, },
telegramUser: { telegramUser: {
id: '123456', id: '123456',
@@ -208,7 +232,8 @@ describe('createMiniAppAuthHandler', () => {
authorized: false, authorized: false,
onboarding: { onboarding: {
status: 'join_required', status: 'join_required',
householdName: 'Kojori House' householdName: 'Kojori House',
householdDefaultLocale: 'ru'
} }
}) })
}) })
@@ -246,7 +271,8 @@ describe('createMiniAppAuthHandler', () => {
authorized: false, authorized: false,
onboarding: { onboarding: {
status: 'pending', status: 'pending',
householdName: 'Kojori House' householdName: 'Kojori House',
householdDefaultLocale: 'ru'
} }
}) })
}) })

View File

@@ -1,4 +1,5 @@
import type { HouseholdOnboardingService } from '@household/application' import type { HouseholdOnboardingService } from '@household/application'
import type { SupportedLocale } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
@@ -101,11 +102,14 @@ export interface MiniAppSessionResult {
householdId: string householdId: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale
} }
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData> telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
onboarding?: { onboarding?: {
status: 'join_required' | 'pending' | 'open_from_group' status: 'join_required' | 'pending' | 'open_from_group'
householdName?: string householdName?: string
householdDefaultLocale?: SupportedLocale
} }
} }
@@ -154,7 +158,8 @@ export function createMiniAppSessionService(options: {
telegramUser, telegramUser,
onboarding: { onboarding: {
status: 'pending', status: 'pending',
householdName: access.household.name householdName: access.household.name,
householdDefaultLocale: access.household.defaultLocale
} }
} }
case 'join_required': case 'join_required':
@@ -163,7 +168,8 @@ export function createMiniAppSessionService(options: {
telegramUser, telegramUser,
onboarding: { onboarding: {
status: 'join_required', status: 'join_required',
householdName: access.household.name householdName: access.household.name,
householdDefaultLocale: access.household.defaultLocale
} }
} }
case 'open_from_group': case 'open_from_group':
@@ -334,7 +340,8 @@ export function createMiniAppJoinHandler(options: {
authorized: false, authorized: false,
onboarding: { onboarding: {
status: 'pending', status: 'pending',
householdName: result.household.name householdName: result.household.name,
householdDefaultLocale: result.household.defaultLocale
}, },
telegramUser telegramUser
}, },

View File

@@ -76,7 +76,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdName: 'Kojori House', householdName: 'Kojori House',
telegramChatId: '-100123', telegramChatId: '-100123',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori House' title: 'Kojori House',
defaultLocale: 'ru' as const
} }
return { return {
@@ -110,7 +111,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
username: input.username?.trim() || null, username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null languageCode: input.languageCode?.trim() || null,
householdDefaultLocale: household.defaultLocale
}), }),
getPendingHouseholdMember: async () => null, getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null,
@@ -119,13 +121,20 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdId: household.householdId, householdId: household.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
listHouseholdMembers: async () => [], listHouseholdMembers: async () => [],
listHouseholdMembersByTelegramUserId: async () => [], listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: 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', householdId: 'household-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: true isAdmin: true
} }
] ]
@@ -223,6 +234,8 @@ describe('createMiniAppDashboardHandler', () => {
householdId: 'household-1', householdId: 'household-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: true isAdmin: true
} }
] ]

View File

@@ -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<string, HouseholdMemberRecord>([
[
'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'
})
})
})

View File

@@ -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<LocalePreferenceRequest> {
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<Response>
} {
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)
}
}
}
}

View File

@@ -9,7 +9,7 @@ import type {
} from '@household/ports' } from '@household/ports'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
export interface PurchaseTopicIngestionConfig { export interface PurchaseTopicIngestionConfig {
householdId: string householdId: string
@@ -325,7 +325,7 @@ export function registerPurchaseTopicIngestion(
try { try {
const status = await repository.save(record, options.llmFallback) const status = await repository.save(record, options.llmFallback)
const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx)) const acknowledgement = buildPurchaseAcknowledgement(status, 'en')
if (status.status === 'created') { if (status.status === 'created') {
options.logger?.info( options.logger?.info(
@@ -395,7 +395,13 @@ export function registerConfiguredPurchaseTopicIngestion(
try { try {
const status = await repository.save(record, options.llmFallback) 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') { if (status.status === 'created') {
options.logger?.info( options.logger?.info(

View File

@@ -32,6 +32,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppLocalePreference?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
scheduler?: scheduler?:
| { | {
pathPrefix?: string pathPrefix?: string
@@ -69,6 +75,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
const miniAppApproveMemberPath = const miniAppApproveMemberPath =
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member' options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member'
const miniAppLocalePreferencePath =
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
const schedulerPathPrefix = options.scheduler const schedulerPathPrefix = options.scheduler
? (options.scheduler.pathPrefix ?? '/jobs/reminder') ? (options.scheduler.pathPrefix ?? '/jobs/reminder')
: null : null
@@ -101,6 +109,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppApproveMember.handler(request) return await options.miniAppApproveMember.handler(request)
} }
if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
return await options.miniAppLocalePreference.handler(request)
}
if (url.pathname !== normalizedWebhookPath) { if (url.pathname !== normalizedWebhookPath) {
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
if (request.method !== 'POST') { if (request.method !== 'POST') {

View File

@@ -7,6 +7,7 @@ import {
fetchMiniAppPendingMembers, fetchMiniAppPendingMembers,
fetchMiniAppSession, fetchMiniAppSession,
joinMiniAppHousehold, joinMiniAppHousehold,
updateMiniAppLocalePreference,
type MiniAppDashboard, type MiniAppDashboard,
type MiniAppPendingMember type MiniAppPendingMember
} from './miniapp-api' } from './miniapp-api'
@@ -36,6 +37,8 @@ type SessionState =
member: { member: {
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
preferredLocale: Locale | null
householdDefaultLocale: Locale
} }
telegramUser: { telegramUser: {
firstName: string | null firstName: string | null
@@ -51,7 +54,9 @@ const demoSession: Extract<SessionState, { status: 'ready' }> = {
mode: 'demo', mode: 'demo',
member: { member: {
displayName: 'Demo Resident', displayName: 'Demo Resident',
isAdmin: false isAdmin: false,
preferredLocale: 'en',
householdDefaultLocale: 'en'
}, },
telegramUser: { telegramUser: {
firstName: 'Demo', firstName: 'Demo',
@@ -112,6 +117,8 @@ function App() {
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([]) const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [joining, setJoining] = createSignal(false) const [joining, setJoining] = createSignal(false)
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const copy = createMemo(() => dictionary[locale()]) const copy = createMemo(() => dictionary[locale()])
const onboardingSession = createMemo(() => { const onboardingSession = createMemo(() => {
@@ -153,7 +160,8 @@ function App() {
} }
async function bootstrap() { async function bootstrap() {
setLocale(detectLocale()) const fallbackLocale = detectLocale()
setLocale(fallbackLocale)
webApp?.ready?.() webApp?.ready?.()
webApp?.expand?.() webApp?.expand?.()
@@ -175,6 +183,10 @@ function App() {
try { try {
const payload = await fetchMiniAppSession(initData, joinContext().joinToken) const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
if (!payload.authorized || !payload.member || !payload.telegramUser) { if (!payload.authorized || !payload.member || !payload.telegramUser) {
setLocale(
payload.onboarding?.householdDefaultLocale ??
((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en')
)
setSession({ setSession({
status: 'onboarding', status: 'onboarding',
mode: payload.onboarding?.status ?? 'open_from_group', mode: payload.onboarding?.status ?? 'open_from_group',
@@ -192,6 +204,7 @@ function App() {
return return
} }
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
setSession({ setSession({
status: 'ready', status: 'ready',
mode: 'live', mode: 'live',
@@ -284,6 +297,7 @@ function App() {
try { try {
const payload = await joinMiniAppHousehold(initData, joinToken) const payload = await joinMiniAppHousehold(initData, joinToken)
if (payload.authorized && payload.member && payload.telegramUser) { if (payload.authorized && payload.member && payload.telegramUser) {
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
setSession({ setSession({
status: 'ready', status: 'ready',
mode: 'live', mode: 'live',
@@ -297,6 +311,10 @@ function App() {
return return
} }
setLocale(
payload.onboarding?.householdDefaultLocale ??
((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en')
)
setSession({ setSession({
status: 'onboarding', status: 'onboarding',
mode: payload.onboarding?.status ?? 'pending', 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 = () => { const renderPanel = () => {
switch (activeNav()) { switch (activeNav()) {
case 'balances': case 'balances':
@@ -396,6 +479,34 @@ function App() {
case 'house': case 'house':
return readySession()?.member.isAdmin ? ( return readySession()?.member.isAdmin ? (
<div class="balance-list"> <div class="balance-list">
<article class="balance-item">
<header>
<strong>{copy().householdLanguage}</strong>
<span>{readySession()?.member.householdDefaultLocale.toUpperCase()}</span>
</header>
<div class="locale-switch__buttons">
<button
classList={{
'is-active': readySession()?.member.householdDefaultLocale === 'en'
}}
type="button"
disabled={savingHouseholdLocale()}
onClick={() => void handleHouseholdLocaleChange('en')}
>
EN
</button>
<button
classList={{
'is-active': readySession()?.member.householdDefaultLocale === 'ru'
}}
type="button"
disabled={savingHouseholdLocale()}
onClick={() => void handleHouseholdLocaleChange('ru')}
>
RU
</button>
</div>
</article>
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{copy().pendingMembersTitle}</strong> <strong>{copy().pendingMembersTitle}</strong>
@@ -470,14 +581,16 @@ function App() {
<button <button
classList={{ 'is-active': locale() === 'en' }} classList={{ 'is-active': locale() === 'en' }}
type="button" type="button"
onClick={() => setLocale('en')} disabled={savingMemberLocale()}
onClick={() => void handleMemberLocaleChange('en')}
> >
EN EN
</button> </button>
<button <button
classList={{ 'is-active': locale() === 'ru' }} classList={{ 'is-active': locale() === 'ru' }}
type="button" type="button"
onClick={() => setLocale('ru')} disabled={savingMemberLocale()}
onClick={() => void handleMemberLocaleChange('ru')}
> >
RU RU
</button> </button>

View File

@@ -27,6 +27,8 @@ export const dictionary = {
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.', 'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
reload: 'Retry', reload: 'Retry',
language: 'Language', language: 'Language',
householdLanguage: 'Household language',
savingLanguage: 'Saving…',
home: 'Home', home: 'Home',
balances: 'Balances', balances: 'Balances',
ledger: 'Ledger', ledger: 'Ledger',
@@ -90,6 +92,8 @@ export const dictionary = {
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.', 'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
reload: 'Повторить', reload: 'Повторить',
language: 'Язык', language: 'Язык',
householdLanguage: 'Язык дома',
savingLanguage: 'Сохраняем…',
home: 'Главная', home: 'Главная',
balances: 'Баланс', balances: 'Баланс',
ledger: 'Леджер', ledger: 'Леджер',

View File

@@ -5,6 +5,8 @@ export interface MiniAppSession {
member?: { member?: {
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
preferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru'
} }
telegramUser?: { telegramUser?: {
firstName: string | null firstName: string | null
@@ -14,9 +16,17 @@ export interface MiniAppSession {
onboarding?: { onboarding?: {
status: 'join_required' | 'pending' | 'open_from_group' status: 'join_required' | 'pending' | 'open_from_group'
householdName?: string householdName?: string
householdDefaultLocale?: 'en' | 'ru'
} }
} }
export interface MiniAppLocalePreference {
scope: 'member' | 'household'
effectiveLocale: 'en' | 'ru'
memberPreferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru'
}
export interface MiniAppPendingMember { export interface MiniAppPendingMember {
telegramUserId: string telegramUserId: string
displayName: string displayName: string
@@ -219,3 +229,34 @@ export async function approveMiniAppPendingMember(
throw new Error(payload.error ?? 'Failed to approve member') throw new Error(payload.error ?? 'Failed to approve member')
} }
} }
export async function updateMiniAppLocalePreference(
initData: string,
locale: 'en' | 'ru',
scope: 'member' | 'household'
): Promise<MiniAppLocalePreference> {
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
}