diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 38d3ed8..2a2a52e 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -14,6 +14,7 @@ function anonUpdate(params: { updateId: number chatType: 'private' | 'supergroup' text: string + languageCode?: string }) { const commandToken = params.text.split(' ')[0] ?? params.text @@ -29,7 +30,12 @@ function anonUpdate(params: { from: { id: 123456, is_bot: false, - first_name: 'Stan' + first_name: 'Stan', + ...(params.languageCode + ? { + language_code: params.languageCode + } + : {}) }, text: params.text, entities: [ @@ -383,6 +389,81 @@ describe('registerAnonymousFeedback', () => { }) }) + test('prompts in Russian for Russian-speaking users', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: 1, + type: 'private' + }, + text: 'ok' + } + } as never + }) + + registerAnonymousFeedback({ + bot, + anonymousFeedbackServiceForHousehold: () => ({ + submit: mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'irrelevant' + })), + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + }), + householdConfigurationRepository: createHouseholdConfigurationRepository(), + promptRepository: createPromptRepository() + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 8001, + chatType: 'private', + text: '/anon', + languageCode: 'ru' + }) as never + ) + + expect(calls[0]?.payload).toMatchObject({ + chat_id: 123456, + text: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Отменить', + callback_data: 'cancel_prompt:anonymous_feedback' + } + ] + ] + } + }) + }) + test('cancels the pending anonymous feedback prompt', async () => { const bot = createTelegramBot('000000:test-token') const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts index 8293d34..5fee009 100644 --- a/apps/bot/src/anonymous-feedback.ts +++ b/apps/bot/src/anonymous-feedback.ts @@ -7,6 +7,8 @@ import type { } from '@household/ports' import type { Bot, Context } from 'grammy' +import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' + const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback' const PENDING_ACTION_TTL_MS = 24 * 60 * 60 * 1000 @@ -19,16 +21,16 @@ function commandArgText(ctx: Context): string { return typeof ctx.match === 'string' ? ctx.match.trim() : '' } -function feedbackText(sanitizedText: string): string { - return ['Anonymous household note', '', sanitizedText].join('\n') +function feedbackText(locale: BotLocale, sanitizedText: string): string { + return [getBotTranslations(locale).anonymousFeedback.title, '', sanitizedText].join('\n') } -function cancelReplyMarkup() { +function cancelReplyMarkup(locale: BotLocale) { return { inline_keyboard: [ [ { - text: 'Cancel', + text: getBotTranslations(locale).anonymousFeedback.cancelButton, callback_data: CANCEL_ANONYMOUS_FEEDBACK_CALLBACK } ] @@ -44,9 +46,10 @@ function shouldKeepPrompt(reason: string): boolean { return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted' } -function formatRetryDelay(now: Instant, nextAllowedAt: Instant): string { +function formatRetryDelay(locale: BotLocale, now: Instant, nextAllowedAt: Instant): string { + const t = getBotTranslations(locale).anonymousFeedback if (Temporal.Instant.compare(nextAllowedAt, now) <= 0) { - return 'now' + return t.retryNow } const duration = now.until(nextAllowedAt, { @@ -59,34 +62,40 @@ function formatRetryDelay(now: Instant, nextAllowedAt: Instant): string { const hours = duration.hours % 24 const parts = [ - days > 0 ? `${days} day${days === 1 ? '' : 's'}` : null, - hours > 0 ? `${hours} hour${hours === 1 ? '' : 's'}` : null, - duration.minutes > 0 ? `${duration.minutes} minute${duration.minutes === 1 ? '' : 's'}` : null + days > 0 ? t.day(days) : null, + hours > 0 ? t.hour(hours) : null, + duration.minutes > 0 ? t.minute(duration.minutes) : null ].filter(Boolean) - return parts.length > 0 ? `in ${parts.join(' ')}` : 'in less than a minute' + return parts.length > 0 ? t.retryIn(parts.join(' ')) : t.retryInLessThanMinute } -function rejectionMessage(reason: string, nextAllowedAt?: Instant, now = nowInstant()): string { +function rejectionMessage( + locale: BotLocale, + reason: string, + nextAllowedAt?: Instant, + now = nowInstant() +): string { + const t = getBotTranslations(locale).anonymousFeedback switch (reason) { case 'not_member': - return 'You are not a member of this household.' + return t.notMember case 'too_short': - return 'Anonymous feedback is too short. Add a little more detail.' + return t.tooShort case 'too_long': - return 'Anonymous feedback is too long. Keep it under 500 characters.' + return t.tooLong case 'cooldown': return nextAllowedAt - ? `Anonymous feedback cooldown is active. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.` - : 'Anonymous feedback cooldown is active. Try again later.' + ? t.cooldown(formatRetryDelay(locale, now, nextAllowedAt)) + : t.cooldown(t.retryInLessThanMinute) case 'daily_cap': return nextAllowedAt - ? `Daily anonymous feedback limit reached. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.` - : 'Daily anonymous feedback limit reached. Try again tomorrow.' + ? t.dailyCap(formatRetryDelay(locale, now, nextAllowedAt)) + : t.dailyCap(t.retryInLessThanMinute) case 'blocklisted': - return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.' + return t.blocklisted default: - return 'Anonymous feedback could not be submitted.' + return t.submitFailed } } @@ -107,10 +116,12 @@ async function startPendingAnonymousFeedbackPrompt( repository: TelegramPendingActionRepository, ctx: Context ): Promise { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale).anonymousFeedback const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() if (!telegramUserId || !telegramChatId) { - await ctx.reply('Unable to start anonymous feedback right now.') + await ctx.reply(t.unableToStart) return } @@ -122,8 +133,8 @@ async function startPendingAnonymousFeedbackPrompt( expiresAt: nowInstant().add({ milliseconds: PENDING_ACTION_TTL_MS }) }) - await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', { - reply_markup: cancelReplyMarkup() + await ctx.reply(t.prompt, { + reply_markup: cancelReplyMarkup(locale) }) } @@ -141,9 +152,11 @@ 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 if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) { - await options.ctx.reply('Unable to identify this message for anonymous feedback.') + await options.ctx.reply(t.unableToIdentifyMessage) return } @@ -154,15 +167,13 @@ async function submitAnonymousFeedback(options: { if (memberships.length === 0) { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply('You are not a member of this household.') + await options.ctx.reply(t.notMember) return } if (memberships.length > 1) { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply( - 'You belong to multiple households. Open the target household from its group until household selection is added.' - ) + await options.ctx.reply(t.multipleHouseholds) return } @@ -176,9 +187,7 @@ async function submitAnonymousFeedback(options: { if (!householdChat || !feedbackTopic) { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply( - 'Anonymous feedback is not configured for your household yet. Ask an admin to run /bind_feedback_topic.' - ) + await options.ctx.reply(t.feedbackTopicMissing) return } @@ -194,7 +203,7 @@ async function submitAnonymousFeedback(options: { if (result.status === 'duplicate') { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply('This anonymous feedback message was already processed.') + await options.ctx.reply(t.duplicate) return } @@ -203,15 +212,18 @@ async function submitAnonymousFeedback(options: { await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) } - const rejectionText = rejectionMessage(result.reason, result.nextAllowedAt, nowInstant()) + const rejectionText = rejectionMessage( + locale, + result.reason, + result.nextAllowedAt, + nowInstant() + ) await options.ctx.reply( - shouldKeepPrompt(result.reason) - ? `${rejectionText} Send a revised message, or tap Cancel.` - : rejectionText, + shouldKeepPrompt(result.reason) ? `${rejectionText} ${t.keepPromptSuffix}` : rejectionText, shouldKeepPrompt(result.reason) ? { - reply_markup: cancelReplyMarkup() + reply_markup: cancelReplyMarkup(locale) } : {} ) @@ -221,7 +233,7 @@ async function submitAnonymousFeedback(options: { try { const posted = await options.ctx.api.sendMessage( householdChat.telegramChatId, - feedbackText(result.sanitizedText), + feedbackText(locale, result.sanitizedText), { message_thread_id: Number(feedbackTopic.telegramThreadId) } @@ -235,7 +247,7 @@ async function submitAnonymousFeedback(options: { }) await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await options.ctx.reply('Anonymous feedback delivered.') + await options.ctx.reply(t.delivered) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown Telegram send failure' options.logger?.error( @@ -249,7 +261,7 @@ async function submitAnonymousFeedback(options: { 'Anonymous feedback posting failed' ) await anonymousFeedbackService.markFailed(result.submissionId, message) - await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') + await options.ctx.reply(t.savedButPostFailed) } } @@ -261,6 +273,8 @@ export function registerAnonymousFeedback(options: { logger?: Logger }): void { options.bot.command('cancel', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale).anonymousFeedback if (!isPrivateChat(ctx)) { return } @@ -268,23 +282,25 @@ export function registerAnonymousFeedback(options: { const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() if (!telegramUserId || !telegramChatId) { - await ctx.reply('Nothing to cancel right now.') + await ctx.reply(t.nothingToCancel) return } const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId) if (!pending) { - await ctx.reply('Nothing to cancel right now.') + await ctx.reply(t.nothingToCancel) return } await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) - await ctx.reply('Cancelled.') + await ctx.reply(t.cancelled) }) options.bot.command('anon', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale).anonymousFeedback if (!isPrivateChat(ctx)) { - await ctx.reply('Use /anon in a private chat with the bot.') + await ctx.reply(t.useInPrivateChat) return } @@ -335,9 +351,11 @@ export function registerAnonymousFeedback(options: { }) options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale).anonymousFeedback if (!isPrivateChat(ctx)) { await ctx.answerCallbackQuery({ - text: 'Use this in a private chat with the bot.', + text: t.useThisInPrivateChat, show_alert: true }) return @@ -345,11 +363,11 @@ export function registerAnonymousFeedback(options: { await clearPendingAnonymousFeedbackPrompt(options.promptRepository, ctx) await ctx.answerCallbackQuery({ - text: 'Cancelled.' + text: t.cancelled }) if (ctx.msg) { - await ctx.editMessageText('Anonymous feedback cancelled.') + await ctx.editMessageText(t.cancelledMessage) } }) } diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index dedf523..596c199 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -2,6 +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' + function commandArgs(ctx: Context): string[] { const raw = typeof ctx.match === 'string' ? ctx.match.trim() : '' if (raw.length === 0) { @@ -21,12 +23,28 @@ export function createFinanceCommandsService(options: { }): { register: (bot: Bot) => void } { + function formatStatement( + ctx: Context, + dashboard: NonNullable>> + ): string { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance + + return [ + t.statementTitle(dashboard.period), + ...dashboard.members.map((line) => + t.statementLine(line.displayName, line.netDue.toMajorString(), dashboard.currency) + ), + t.statementTotal(dashboard.totalDue.toMajorString(), dashboard.currency) + ].join('\n') + } + async function resolveGroupFinanceService(ctx: Context): Promise<{ service: FinanceCommandService householdId: string } | null> { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance if (!isGroupChat(ctx)) { - await ctx.reply('Use this command inside a household group.') + await ctx.reply(t.useInGroup) return null } @@ -34,7 +52,7 @@ export function createFinanceCommandsService(options: { ctx.chat!.id.toString() ) if (!household) { - await ctx.reply('Household is not configured for this chat yet. Run /setup first.') + await ctx.reply(t.householdNotConfigured) return null } @@ -45,9 +63,10 @@ export function createFinanceCommandsService(options: { } async function requireMember(ctx: Context) { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const telegramUserId = ctx.from?.id?.toString() if (!telegramUserId) { - await ctx.reply('Unable to identify sender for this command.') + await ctx.reply(t.unableToIdentifySender) return null } @@ -58,7 +77,7 @@ export function createFinanceCommandsService(options: { const member = await scoped.service.getMemberByTelegramUserId(telegramUserId) if (!member) { - await ctx.reply('You are not a member of this household.') + await ctx.reply(t.notMember) return null } @@ -70,13 +89,14 @@ export function createFinanceCommandsService(options: { } async function requireAdmin(ctx: Context) { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const resolved = await requireMember(ctx) if (!resolved) { return null } if (!resolved.member.isAdmin) { - await ctx.reply('Only household admins can use this command.') + await ctx.reply(t.adminOnly) return null } @@ -85,6 +105,7 @@ export function createFinanceCommandsService(options: { function register(bot: Bot): void { bot.command('cycle_open', async (ctx) => { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -92,19 +113,20 @@ export function createFinanceCommandsService(options: { const args = commandArgs(ctx) if (args.length === 0) { - await ctx.reply('Usage: /cycle_open [USD|GEL]') + await ctx.reply(t.cycleOpenUsage) return } try { const cycle = await resolved.service.openCycle(args[0]!, args[1]) - await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`) + await ctx.reply(t.cycleOpened(cycle.period, cycle.currency)) } catch (error) { - await ctx.reply(`Failed to open cycle: ${(error as Error).message}`) + await ctx.reply(t.cycleOpenFailed((error as Error).message)) } }) bot.command('cycle_close', async (ctx) => { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -113,17 +135,18 @@ export function createFinanceCommandsService(options: { try { const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0]) if (!cycle) { - await ctx.reply('No cycle found to close.') + await ctx.reply(t.noCycleToClose) return } - await ctx.reply(`Cycle closed: ${cycle.period}`) + await ctx.reply(t.cycleClosed(cycle.period)) } catch (error) { - await ctx.reply(`Failed to close cycle: ${(error as Error).message}`) + await ctx.reply(t.cycleCloseFailed((error as Error).message)) } }) bot.command('rent_set', async (ctx) => { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -131,26 +154,25 @@ export function createFinanceCommandsService(options: { const args = commandArgs(ctx) if (args.length === 0) { - await ctx.reply('Usage: /rent_set [USD|GEL] [YYYY-MM]') + await ctx.reply(t.rentSetUsage) return } try { const result = await resolved.service.setRent(args[0]!, args[1], args[2]) if (!result) { - await ctx.reply('No period provided and no open cycle found.') + await ctx.reply(t.rentNoPeriod) return } - await ctx.reply( - `Rent rule saved: ${result.amount.toMajorString()} ${result.currency} starting ${result.period}` - ) + await ctx.reply(t.rentSaved(result.amount.toMajorString(), result.currency, result.period)) } catch (error) { - await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`) + await ctx.reply(t.rentSaveFailed((error as Error).message)) } }) bot.command('utility_add', async (ctx) => { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const resolved = await requireAdmin(ctx) if (!resolved) { return @@ -158,7 +180,7 @@ export function createFinanceCommandsService(options: { const args = commandArgs(ctx) if (args.length < 2) { - await ctx.reply('Usage: /utility_add [USD|GEL]') + await ctx.reply(t.utilityAddUsage) return } @@ -170,34 +192,35 @@ export function createFinanceCommandsService(options: { args[2] ) if (!result) { - await ctx.reply('No open cycle found. Use /cycle_open first.') + await ctx.reply(t.utilityNoOpenCycle) return } await ctx.reply( - `Utility bill added: ${args[0]} ${result.amount.toMajorString()} ${result.currency} for ${result.period}` + t.utilityAdded(args[0]!, result.amount.toMajorString(), result.currency, result.period) ) } catch (error) { - await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`) + await ctx.reply(t.utilityAddFailed((error as Error).message)) } }) bot.command('statement', async (ctx) => { + const t = getBotTranslations(botLocaleFromContext(ctx)).finance const resolved = await requireMember(ctx) if (!resolved) { return } try { - const statement = await resolved.service.generateStatement(commandArgs(ctx)[0]) - if (!statement) { - await ctx.reply('No cycle found for statement.') + const dashboard = await resolved.service.generateDashboard(commandArgs(ctx)[0]) + if (!dashboard) { + await ctx.reply(t.noStatementCycle) return } - await ctx.reply(statement) + await ctx.reply(formatStatement(ctx, dashboard)) } catch (error) { - await ctx.reply(`Failed to generate statement: ${(error as Error).message}`) + await ctx.reply(t.statementFailed((error as Error).message)) } }) } diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 689b7ee..7533024 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -9,7 +9,7 @@ import type { import { createTelegramBot } from './bot' import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup' -function startUpdate(text: string) { +function startUpdate(text: string, languageCode?: string) { const commandToken = text.split(' ')[0] ?? text return { @@ -24,7 +24,12 @@ function startUpdate(text: string) { from: { id: 123456, is_bot: false, - first_name: 'Stan' + first_name: 'Stan', + ...(languageCode + ? { + language_code: languageCode + } + : {}) }, text, entities: [ @@ -177,4 +182,91 @@ describe('registerHouseholdSetupCommands', () => { } }) }) + + test('localizes the DM join response for Russian users', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: 123456, + type: 'private' + }, + text: 'ok' + } + } as never + }) + + const householdOnboardingService: HouseholdOnboardingService = { + async ensureHouseholdJoinToken() { + return { + householdId: 'household-1', + householdName: 'Kojori House', + token: 'join-token' + } + }, + async getMiniAppAccess() { + return { + status: 'open_from_group' + } + }, + async joinHousehold() { + return { + status: 'pending', + household: { + id: 'household-1', + name: 'Kojori House' + } + } + } + } + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(), + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + miniAppUrl: 'https://miniapp.example.app' + }) + + await bot.handleUpdate(startUpdate('/start join_join-token', 'ru') as never) + + expect(calls[0]?.payload).toMatchObject({ + chat_id: 123456, + text: 'Заявка на вступление в Kojori House отправлена. Дождитесь подтверждения от админа дома.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Открыть мини-приложение', + web_app: { + url: 'https://miniapp.example.app/?join=join-token&bot=household_test_bot' + } + } + ] + ] + } + }) + }) }) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 7db0d88..9cbe849 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -6,6 +6,8 @@ import type { import type { Logger } from '@household/observability' import type { Bot, Context } from 'grammy' +import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' + const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' function commandArgText(ctx: Context): string { @@ -32,38 +34,46 @@ async function isGroupAdmin(ctx: Context): Promise { return member.status === 'creator' || member.status === 'administrator' } -function setupRejectionMessage(reason: 'not_admin' | 'invalid_chat_type'): string { +function setupRejectionMessage( + locale: BotLocale, + reason: 'not_admin' | 'invalid_chat_type' +): string { + const t = getBotTranslations(locale).setup switch (reason) { case 'not_admin': - return 'Only Telegram group admins can run /setup.' + return t.onlyTelegramAdmins case 'invalid_chat_type': - return 'Use /setup inside a group or supergroup.' + return t.useSetupInGroup } } function bindRejectionMessage( + locale: BotLocale, reason: 'not_admin' | 'household_not_found' | 'not_topic_message' ): string { + const t = getBotTranslations(locale).setup switch (reason) { case 'not_admin': - return 'Only Telegram group admins can bind household topics.' + return t.onlyTelegramAdminsBindTopics case 'household_not_found': - return 'Household is not configured for this chat yet. Run /setup first.' + return t.householdNotConfigured case 'not_topic_message': - return 'Run this command inside the target topic thread.' + return t.useCommandInTopic } } function adminRejectionMessage( + locale: BotLocale, reason: 'not_admin' | 'household_not_found' | 'pending_not_found' ): string { + const t = getBotTranslations(locale).setup switch (reason) { case 'not_admin': - return 'Only household admins can manage pending members.' + return t.onlyHouseholdAdmins case 'household_not_found': - return 'Household is not configured for this chat yet. Run /setup first.' + return t.householdNotConfigured case 'pending_not_found': - return 'Pending member not found. Use /pending_members to inspect the queue.' + return t.pendingNotFound } } @@ -84,27 +94,28 @@ function buildPendingMemberLabel(displayName: string): string { return `${normalized.slice(0, 29)}...` } -function pendingMembersReply(result: { - householdName: string - members: readonly { - telegramUserId: string - displayName: string - username?: string | null - }[] -}) { +function pendingMembersReply( + locale: BotLocale, + result: { + householdName: string + members: readonly { + telegramUserId: string + displayName: string + username?: string | null + }[] + } +) { + const t = getBotTranslations(locale).setup return { text: [ - `Pending members for ${result.householdName}:`, - ...result.members.map( - (member, index) => - `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}` - ), - 'Tap a button below to approve, or use /approve_member .' + t.pendingMembersHeading(result.householdName), + ...result.members.map((member, index) => t.pendingMemberLine(member, index)), + t.pendingMembersHint ].join('\n'), reply_markup: { inline_keyboard: result.members.map((member) => [ { - text: `Approve ${buildPendingMemberLabel(member.displayName)}`, + text: t.approveMemberButton(buildPendingMemberLabel(member.displayName)), callback_data: `${APPROVE_MEMBER_CALLBACK_PREFIX}${member.telegramUserId}` } ]) @@ -133,6 +144,7 @@ export function buildJoinMiniAppUrl( } function miniAppReplyMarkup( + locale: BotLocale, miniAppUrl: string | undefined, botUsername: string | undefined, joinToken: string @@ -147,7 +159,7 @@ function miniAppReplyMarkup( inline_keyboard: [ [ { - text: 'Open mini app', + text: getBotTranslations(locale).setup.openMiniAppButton, web_app: { url: webAppUrl } @@ -167,24 +179,27 @@ export function registerHouseholdSetupCommands(options: { logger?: Logger }): void { options.bot.command('start', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (ctx.chat?.type !== 'private') { return } if (!ctx.from) { - await ctx.reply('Telegram user identity is required to join a household.') + await ctx.reply(t.setup.telegramIdentityRequired) return } const startPayload = commandArgText(ctx) if (!startPayload.startsWith('join_')) { - await ctx.reply('Send /help to see available commands.') + await ctx.reply(t.common.useHelp) return } const joinToken = startPayload.slice('join_'.length).trim() if (!joinToken) { - await ctx.reply('Invalid household invite link.') + await ctx.reply(t.setup.invalidJoinLink) return } @@ -212,27 +227,30 @@ export function registerHouseholdSetupCommands(options: { }) if (result.status === 'invalid_token') { - await ctx.reply('This household invite link is invalid or expired.') + await ctx.reply(t.setup.joinLinkInvalidOrExpired) return } if (result.status === 'active') { await ctx.reply( - `You are already an active member. Open the mini app to view ${result.member.displayName}.`, - miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken) + t.setup.alreadyActiveMember(result.member.displayName), + miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, joinToken) ) return } await ctx.reply( - `Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`, - miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken) + t.setup.joinRequestSent(result.household.name), + miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, joinToken) ) }) options.bot.command('setup', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (!isGroupChat(ctx)) { - await ctx.reply('Use /setup inside the household group.') + await ctx.reply(t.setup.useSetupInGroup) return } @@ -256,7 +274,7 @@ export function registerHouseholdSetupCommands(options: { }) if (result.status === 'rejected') { - await ctx.reply(setupRejectionMessage(result.reason)) + await ctx.reply(setupRejectionMessage(locale, result.reason)) return } @@ -285,19 +303,18 @@ export function registerHouseholdSetupCommands(options: { ? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}` : null await ctx.reply( - [ - `Household ${action}: ${result.household.householdName}`, - `Chat ID: ${result.household.telegramChatId}`, - 'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.', - 'Members should open the bot chat from the button below and confirm the join request there.' - ].join('\n'), + t.setup.setupSummary({ + householdName: result.household.householdName, + telegramChatId: result.household.telegramChatId, + created: action === 'created' + }), joinDeepLink ? { reply_markup: { inline_keyboard: [ [ { - text: 'Join household', + text: t.setup.joinHouseholdButton, url: joinDeepLink } ] @@ -309,8 +326,11 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('bind_purchase_topic', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (!isGroupChat(ctx)) { - await ctx.reply('Use /bind_purchase_topic inside the household group topic.') + await ctx.reply(t.setup.useBindPurchaseTopicInGroup) return } @@ -331,7 +351,7 @@ export function registerHouseholdSetupCommands(options: { }) if (result.status === 'rejected') { - await ctx.reply(bindRejectionMessage(result.reason)) + await ctx.reply(bindRejectionMessage(locale, result.reason)) return } @@ -348,13 +368,16 @@ export function registerHouseholdSetupCommands(options: { ) await ctx.reply( - `Purchase topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).` + t.setup.purchaseTopicSaved(result.household.householdName, result.binding.telegramThreadId) ) }) options.bot.command('bind_feedback_topic', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (!isGroupChat(ctx)) { - await ctx.reply('Use /bind_feedback_topic inside the household group topic.') + await ctx.reply(t.setup.useBindFeedbackTopicInGroup) return } @@ -375,7 +398,7 @@ export function registerHouseholdSetupCommands(options: { }) if (result.status === 'rejected') { - await ctx.reply(bindRejectionMessage(result.reason)) + await ctx.reply(bindRejectionMessage(locale, result.reason)) return } @@ -392,19 +415,22 @@ export function registerHouseholdSetupCommands(options: { ) await ctx.reply( - `Feedback topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).` + t.setup.feedbackTopicSaved(result.household.householdName, result.binding.telegramThreadId) ) }) options.bot.command('pending_members', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (!isGroupChat(ctx)) { - await ctx.reply('Use /pending_members inside the household group.') + await ctx.reply(t.setup.usePendingMembersInGroup) return } const actorTelegramUserId = ctx.from?.id?.toString() if (!actorTelegramUserId) { - await ctx.reply('Unable to identify sender for this command.') + await ctx.reply(t.common.unableToIdentifySender) return } @@ -414,36 +440,39 @@ export function registerHouseholdSetupCommands(options: { }) if (result.status === 'rejected') { - await ctx.reply(adminRejectionMessage(result.reason)) + await ctx.reply(adminRejectionMessage(locale, result.reason)) return } if (result.members.length === 0) { - await ctx.reply(`No pending members for ${result.householdName}.`) + await ctx.reply(t.setup.pendingMembersEmpty(result.householdName)) return } - const reply = pendingMembersReply(result) + const reply = pendingMembersReply(locale, result) await ctx.reply(reply.text, { reply_markup: reply.reply_markup }) }) options.bot.command('approve_member', async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (!isGroupChat(ctx)) { - await ctx.reply('Use /approve_member inside the household group.') + await ctx.reply(t.setup.useApproveMemberInGroup) return } const actorTelegramUserId = ctx.from?.id?.toString() if (!actorTelegramUserId) { - await ctx.reply('Unable to identify sender for this command.') + await ctx.reply(t.common.unableToIdentifySender) return } const pendingTelegramUserId = commandArgText(ctx) if (!pendingTelegramUserId) { - await ctx.reply('Usage: /approve_member ') + await ctx.reply(t.setup.approveMemberUsage) return } @@ -454,21 +483,22 @@ export function registerHouseholdSetupCommands(options: { }) if (result.status === 'rejected') { - await ctx.reply(adminRejectionMessage(result.reason)) + await ctx.reply(adminRejectionMessage(locale, result.reason)) return } - await ctx.reply( - `Approved ${result.member.displayName} as an active member of ${result.householdName}.` - ) + await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName)) }) options.bot.callbackQuery( new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`), async (ctx) => { + const locale = botLocaleFromContext(ctx) + const t = getBotTranslations(locale) + if (!isGroupChat(ctx)) { await ctx.answerCallbackQuery({ - text: 'Use this button in the household group.', + text: t.setup.useButtonInGroup, show_alert: true }) return @@ -478,7 +508,7 @@ export function registerHouseholdSetupCommands(options: { const pendingTelegramUserId = ctx.match[1] if (!actorTelegramUserId || !pendingTelegramUserId) { await ctx.answerCallbackQuery({ - text: 'Unable to identify the selected member.', + text: t.setup.unableToIdentifySelectedMember, show_alert: true }) return @@ -492,14 +522,14 @@ export function registerHouseholdSetupCommands(options: { if (result.status === 'rejected') { await ctx.answerCallbackQuery({ - text: adminRejectionMessage(result.reason), + text: adminRejectionMessage(locale, result.reason), show_alert: true }) return } await ctx.answerCallbackQuery({ - text: `Approved ${result.member.displayName}.` + text: t.setup.approvedMemberToast(result.member.displayName) }) if (ctx.msg) { @@ -510,9 +540,9 @@ export function registerHouseholdSetupCommands(options: { if (refreshed.status === 'ok') { if (refreshed.members.length === 0) { - await ctx.editMessageText(`No pending members for ${refreshed.householdName}.`) + await ctx.editMessageText(t.setup.pendingMembersEmpty(refreshed.householdName)) } else { - const reply = pendingMembersReply(refreshed) + const reply = pendingMembersReply(locale, refreshed) await ctx.editMessageText(reply.text, { reply_markup: reply.reply_markup }) @@ -520,9 +550,7 @@ export function registerHouseholdSetupCommands(options: { } } - await ctx.reply( - `Approved ${result.member.displayName} as an active member of ${result.householdName}.` - ) + await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName)) } ) } diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index aa1271f..fd857e6 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -176,6 +176,23 @@ describe('buildPurchaseAcknowledgement', () => { }) ).toBeNull() }) + + test('returns Russian acknowledgement when requested', () => { + const result = buildPurchaseAcknowledgement( + { + status: 'created', + processingStatus: 'parsed', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'туалетная бумага', + parserConfidence: 92, + parserMode: 'rules' + }, + 'ru' + ) + + expect(result).toBe('Покупка сохранена: туалетная бумага - 30.00 GEL') + }) }) describe('registerPurchaseTopicIngestion', () => { diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 9c83c2e..c47fce0 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -9,6 +9,7 @@ import type { } from '@household/ports' import { createDbClient, schema } from '@household/db' +import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n' export interface PurchaseTopicIngestionConfig { householdId: string @@ -216,6 +217,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): { } function formatPurchaseSummary( + locale: BotLocale, result: Extract ): string { if ( @@ -223,7 +225,7 @@ function formatPurchaseSummary( result.parsedCurrency === null || result.parsedItemDescription === null ) { - return 'shared purchase' + return getBotTranslations(locale).purchase.sharedPurchaseFallback } const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency) @@ -231,19 +233,22 @@ function formatPurchaseSummary( } export function buildPurchaseAcknowledgement( - result: PurchaseMessageIngestionResult + result: PurchaseMessageIngestionResult, + locale: BotLocale = 'en' ): string | null { if (result.status === 'duplicate') { return null } + const t = getBotTranslations(locale).purchase + switch (result.processingStatus) { case 'parsed': - return `Recorded purchase: ${formatPurchaseSummary(result)}` + return t.recorded(formatPurchaseSummary(locale, result)) case 'needs_review': - return `Saved for review: ${formatPurchaseSummary(result)}` + return t.savedForReview(formatPurchaseSummary(locale, result)) case 'parse_failed': - return "Saved for review: I couldn't parse this purchase yet." + return t.parseFailed } } @@ -320,7 +325,7 @@ export function registerPurchaseTopicIngestion( try { const status = await repository.save(record, options.llmFallback) - const acknowledgement = buildPurchaseAcknowledgement(status) + const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx)) if (status.status === 'created') { options.logger?.info( @@ -390,7 +395,7 @@ export function registerConfiguredPurchaseTopicIngestion( try { const status = await repository.save(record, options.llmFallback) - const acknowledgement = buildPurchaseAcknowledgement(status) + const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx)) if (status.status === 'created') { options.logger?.info(