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',
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 часов.'
})
})
})

View File

@@ -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<void> {
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({

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 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)
})

View File

@@ -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<typeof getBotTranslations>[0],
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
): 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))
}

View File

@@ -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'
}
}
}

View File

@@ -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
}
}

View File

@@ -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<void>> = []
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<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
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
? {

View File

@@ -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
}
})

View File

@@ -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'
}
})
})

View File

@@ -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<typeof verifyTelegramMiniAppInitData>
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
},

View File

@@ -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
}
]

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'
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(

View File

@@ -32,6 +32,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppLocalePreference?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| 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') {