From 64b3e4f01ef85a819fcd33470c77121d0deb31cb Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 07:34:48 +0400 Subject: [PATCH] feat(bot): add Telegram bot i18n foundation --- apps/bot/src/bot-i18n.test.ts | 79 ++++++++++++++++ apps/bot/src/bot.ts | 7 +- apps/bot/src/i18n/index.ts | 29 ++++++ apps/bot/src/i18n/locales/en.ts | 142 ++++++++++++++++++++++++++++ apps/bot/src/i18n/locales/ru.ts | 145 +++++++++++++++++++++++++++++ apps/bot/src/i18n/types.ts | 147 +++++++++++++++++++++++++++++ apps/bot/src/telegram-commands.ts | 140 +++++++++++++--------------- scripts/ops/telegram-commands.ts | 149 +++++++++++++++++++----------- 8 files changed, 709 insertions(+), 129 deletions(-) create mode 100644 apps/bot/src/bot-i18n.test.ts create mode 100644 apps/bot/src/i18n/index.ts create mode 100644 apps/bot/src/i18n/locales/en.ts create mode 100644 apps/bot/src/i18n/locales/ru.ts create mode 100644 apps/bot/src/i18n/types.ts diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts new file mode 100644 index 0000000..5fc6ab0 --- /dev/null +++ b/apps/bot/src/bot-i18n.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' + +import { createTelegramBot } from './bot' + +function helpUpdate(languageCode: string) { + return { + update_id: 9001, + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { + id: 123456, + type: 'private' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: languageCode + }, + text: '/help', + entities: [ + { + offset: 0, + length: 5, + type: 'bot_command' + } + ] + } + } +} + +describe('createTelegramBot i18n', () => { + test('replies with Russian help text 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 + }) + + await bot.handleUpdate(helpUpdate('ru') as never) + + expect(calls[0]?.payload).toMatchObject({ + chat_id: 123456 + }) + + const payload = calls[0]?.payload as { text?: string } | undefined + expect(payload?.text).toContain('Бот для дома подключен.') + expect(payload?.text).toContain('/anon - Отправить анонимное сообщение по дому') + }) +}) diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 2b268ed..7d69489 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,17 +1,20 @@ import { Bot } from 'grammy' import type { Logger } from '@household/observability' +import { botLocaleFromContext, getBotTranslations } from './i18n' import { formatTelegramHelpText } from './telegram-commands' export function createTelegramBot(token: string, logger?: Logger): Bot { const bot = new Bot(token) bot.command('help', async (ctx) => { - await ctx.reply(formatTelegramHelpText()) + const locale = botLocaleFromContext(ctx) + await ctx.reply(formatTelegramHelpText(locale)) }) bot.command('household_status', async (ctx) => { - await ctx.reply('Household status is not connected yet. Data integration is next.') + const locale = botLocaleFromContext(ctx) + await ctx.reply(getBotTranslations(locale).bot.householdStatusPending) }) bot.catch((error) => { diff --git a/apps/bot/src/i18n/index.ts b/apps/bot/src/i18n/index.ts new file mode 100644 index 0000000..e4a2e1a --- /dev/null +++ b/apps/bot/src/i18n/index.ts @@ -0,0 +1,29 @@ +import type { Context } from 'grammy' + +import { enBotTranslations } from './locales/en' +import { ruBotTranslations } from './locales/ru' +import type { BotLocale, BotTranslationCatalog } from './types' + +const catalogs: Record = { + en: enBotTranslations, + ru: ruBotTranslations +} + +export function resolveBotLocale(languageCode?: string | null): BotLocale { + const normalized = languageCode?.trim().toLowerCase() + if (normalized?.startsWith('ru')) { + return 'ru' + } + + return 'en' +} + +export function botLocaleFromContext(ctx: Pick): BotLocale { + return resolveBotLocale(ctx.from?.language_code) +} + +export function getBotTranslations(locale: BotLocale): BotTranslationCatalog { + return catalogs[locale] +} + +export type { BotLocale } from './types' diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts new file mode 100644 index 0000000..358fe44 --- /dev/null +++ b/apps/bot/src/i18n/locales/en.ts @@ -0,0 +1,142 @@ +import type { BotTranslationCatalog } from '../types' + +export const enBotTranslations: BotTranslationCatalog = { + localeName: 'English', + commands: { + help: 'Show command list', + household_status: 'Show current household status', + anon: 'Send anonymous household feedback', + cancel: 'Cancel the current prompt', + setup: 'Register this group as a household', + bind_purchase_topic: 'Bind the current topic as purchases', + bind_feedback_topic: 'Bind the current topic as feedback', + pending_members: 'List pending household join requests', + approve_member: 'Approve a pending household member' + }, + help: { + intro: 'Household bot is live.', + privateChatHeading: 'Private chat:', + groupAdminsHeading: 'Group admins:' + }, + bot: { + householdStatusPending: 'Household status is not connected yet. Data integration is next.' + }, + common: { + unableToIdentifySender: 'Unable to identify sender for this command.', + useHelp: 'Send /help to see available commands.' + }, + setup: { + onlyTelegramAdmins: 'Only Telegram group admins can run /setup.', + useSetupInGroup: 'Use /setup inside the household group.', + onlyTelegramAdminsBindTopics: 'Only Telegram group admins can bind household topics.', + householdNotConfigured: 'Household is not configured for this chat yet. Run /setup first.', + useCommandInTopic: 'Run this command inside the target topic thread.', + onlyHouseholdAdmins: 'Only household admins can manage pending members.', + pendingNotFound: 'Pending member not found. Use /pending_members to inspect the queue.', + pendingMembersHeading: (householdName) => `Pending members for ${householdName}:`, + pendingMembersHint: 'Tap a button below to approve, or use /approve_member .', + pendingMembersEmpty: (householdName) => `No pending members for ${householdName}.`, + pendingMemberLine: (member, index) => + `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`, + openMiniAppButton: 'Open mini app', + joinHouseholdButton: 'Join household', + approveMemberButton: (displayName) => `Approve ${displayName}`, + telegramIdentityRequired: 'Telegram user identity is required to join a household.', + invalidJoinLink: 'Invalid household invite link.', + joinLinkInvalidOrExpired: 'This household invite link is invalid or expired.', + alreadyActiveMember: (displayName) => + `You are already an active member. Open the mini app to view ${displayName}.`, + joinRequestSent: (householdName) => + `Join request sent for ${householdName}. Wait for a household admin to confirm you.`, + setupSummary: ({ householdName, telegramChatId, created }) => + [ + `Household ${created ? 'created' : 'already registered'}: ${householdName}`, + `Chat ID: ${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'), + useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.', + purchaseTopicSaved: (householdName, threadId) => + `Purchase topic saved for ${householdName} (thread ${threadId}).`, + useBindFeedbackTopicInGroup: 'Use /bind_feedback_topic inside the household group topic.', + feedbackTopicSaved: (householdName, threadId) => + `Feedback topic saved for ${householdName} (thread ${threadId}).`, + usePendingMembersInGroup: 'Use /pending_members inside the household group.', + useApproveMemberInGroup: 'Use /approve_member inside the household group.', + approveMemberUsage: 'Usage: /approve_member ', + approvedMember: (displayName, householdName) => + `Approved ${displayName} as an active member of ${householdName}.`, + useButtonInGroup: 'Use this button in the household group.', + unableToIdentifySelectedMember: 'Unable to identify the selected member.', + approvedMemberToast: (displayName) => `Approved ${displayName}.` + }, + anonymousFeedback: { + title: 'Anonymous household note', + cancelButton: 'Cancel', + unableToStart: 'Unable to start anonymous feedback right now.', + prompt: 'Send me the anonymous message in your next reply, or tap Cancel.', + unableToIdentifyMessage: 'Unable to identify this message for anonymous feedback.', + notMember: 'You are not a member of this household.', + multipleHouseholds: + 'You belong to multiple households. Open the target household from its group until household selection is added.', + feedbackTopicMissing: + 'Anonymous feedback is not configured for your household yet. Ask an admin to run /bind_feedback_topic.', + duplicate: 'This anonymous feedback message was already processed.', + delivered: 'Anonymous feedback delivered.', + savedButPostFailed: 'Anonymous feedback was saved, but posting failed. Try again later.', + nothingToCancel: 'Nothing to cancel right now.', + cancelled: 'Cancelled.', + cancelledMessage: 'Anonymous feedback cancelled.', + useInPrivateChat: 'Use /anon in a private chat with the bot.', + useThisInPrivateChat: 'Use this in a private chat with the bot.', + tooShort: 'Anonymous feedback is too short. Add a little more detail.', + tooLong: 'Anonymous feedback is too long. Keep it under 500 characters.', + cooldown: (retryDelay) => + `Anonymous feedback cooldown is active. You can send the next message ${retryDelay}.`, + dailyCap: (retryDelay) => + `Daily anonymous feedback limit reached. You can send the next message ${retryDelay}.`, + blocklisted: 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.', + submitFailed: 'Anonymous feedback could not be submitted.', + keepPromptSuffix: 'Send a revised message, or tap Cancel.', + retryNow: 'now', + retryInLessThanMinute: 'in less than a minute', + retryIn: (parts) => `in ${parts}`, + day: (count) => `${count} day${count === 1 ? '' : 's'}`, + hour: (count) => `${count} hour${count === 1 ? '' : 's'}`, + minute: (count) => `${count} minute${count === 1 ? '' : 's'}` + }, + finance: { + useInGroup: 'Use this command inside a household group.', + householdNotConfigured: 'Household is not configured for this chat yet. Run /setup first.', + unableToIdentifySender: 'Unable to identify sender for this command.', + notMember: 'You are not a member of this household.', + adminOnly: 'Only household admins can use this command.', + cycleOpenUsage: 'Usage: /cycle_open [USD|GEL]', + cycleOpened: (period, currency) => `Cycle opened: ${period} (${currency})`, + cycleOpenFailed: (message) => `Failed to open cycle: ${message}`, + noCycleToClose: 'No cycle found to close.', + cycleClosed: (period) => `Cycle closed: ${period}`, + cycleCloseFailed: (message) => `Failed to close cycle: ${message}`, + rentSetUsage: 'Usage: /rent_set [USD|GEL] [YYYY-MM]', + rentNoPeriod: 'No period provided and no open cycle found.', + rentSaved: (amount, currency, period) => + `Rent rule saved: ${amount} ${currency} starting ${period}`, + rentSaveFailed: (message) => `Failed to save rent rule: ${message}`, + utilityAddUsage: 'Usage: /utility_add [USD|GEL]', + utilityNoOpenCycle: 'No open cycle found. Use /cycle_open first.', + utilityAdded: (name, amount, currency, period) => + `Utility bill added: ${name} ${amount} ${currency} for ${period}`, + utilityAddFailed: (message) => `Failed to add utility bill: ${message}`, + noStatementCycle: 'No cycle found for statement.', + statementTitle: (period) => `Statement for ${period}`, + statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, + statementTotal: (amount, currency) => `Total: ${amount} ${currency}`, + statementFailed: (message) => `Failed to generate statement: ${message}` + }, + purchase: { + sharedPurchaseFallback: 'shared purchase', + recorded: (summary) => `Recorded purchase: ${summary}`, + savedForReview: (summary) => `Saved for review: ${summary}`, + parseFailed: "Saved for review: I couldn't parse this purchase yet." + } +} diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts new file mode 100644 index 0000000..5637207 --- /dev/null +++ b/apps/bot/src/i18n/locales/ru.ts @@ -0,0 +1,145 @@ +import type { BotTranslationCatalog } from '../types' + +export const ruBotTranslations: BotTranslationCatalog = { + localeName: 'Русский', + commands: { + help: 'Показать список команд', + household_status: 'Показать текущий статус дома', + anon: 'Отправить анонимное сообщение по дому', + cancel: 'Отменить текущий ввод', + setup: 'Подключить эту группу как дом', + bind_purchase_topic: 'Назначить текущий топик для покупок', + bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', + pending_members: 'Показать ожидающие заявки на вступление', + approve_member: 'Подтвердить участника дома' + }, + help: { + intro: 'Бот для дома подключен.', + privateChatHeading: 'Личный чат:', + groupAdminsHeading: 'Админы группы:' + }, + bot: { + householdStatusPending: 'Статус дома пока не подключен. Интеграция данных будет следующей.' + }, + common: { + unableToIdentifySender: 'Не удалось определить отправителя для этой команды.', + useHelp: 'Отправьте /help, чтобы увидеть доступные команды.' + }, + setup: { + onlyTelegramAdmins: 'Только админы Telegram-группы могут запускать /setup.', + useSetupInGroup: 'Используйте /setup внутри группы дома.', + onlyTelegramAdminsBindTopics: 'Только админы Telegram-группы могут привязывать топики дома.', + householdNotConfigured: 'Для этого чата дом ещё не настроен. Сначала выполните /setup.', + useCommandInTopic: 'Запустите эту команду внутри нужного топика.', + onlyHouseholdAdmins: 'Только админы дома могут управлять ожидающими участниками.', + pendingNotFound: + 'Ожидающий участник не найден. Используйте /pending_members, чтобы посмотреть очередь.', + pendingMembersHeading: (householdName) => `Ожидающие участники для ${householdName}:`, + pendingMembersHint: + 'Нажмите кнопку ниже, чтобы подтвердить участника, или используйте /approve_member .', + pendingMembersEmpty: (householdName) => `Для ${householdName} нет ожидающих участников.`, + pendingMemberLine: (member, index) => + `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`, + openMiniAppButton: 'Открыть мини-приложение', + joinHouseholdButton: 'Вступить в дом', + approveMemberButton: (displayName) => `Подтвердить ${displayName}`, + telegramIdentityRequired: 'Чтобы вступить в дом, нужна Telegram-учётка пользователя.', + invalidJoinLink: 'Некорректная ссылка-приглашение в дом.', + joinLinkInvalidOrExpired: 'Эта ссылка-приглашение в дом недействительна или устарела.', + alreadyActiveMember: (displayName) => + `Вы уже активный участник. Откройте мини-приложение, чтобы увидеть профиль ${displayName}.`, + joinRequestSent: (householdName) => + `Заявка на вступление в ${householdName} отправлена. Дождитесь подтверждения от админа дома.`, + setupSummary: ({ householdName, telegramChatId, created }) => + [ + `${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`, + `ID чата: ${telegramChatId}`, + 'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic.', + 'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.' + ].join('\n'), + useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.', + purchaseTopicSaved: (householdName, threadId) => + `Топик покупок сохранён для ${householdName} (тред ${threadId}).`, + useBindFeedbackTopicInGroup: 'Используйте /bind_feedback_topic внутри топика группы дома.', + feedbackTopicSaved: (householdName, threadId) => + `Топик обратной связи сохранён для ${householdName} (тред ${threadId}).`, + usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.', + useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.', + approveMemberUsage: 'Использование: /approve_member ', + approvedMember: (displayName, householdName) => + `Участник ${displayName} подтверждён как активный участник ${householdName}.`, + useButtonInGroup: 'Используйте эту кнопку в группе дома.', + unableToIdentifySelectedMember: 'Не удалось определить выбранного участника.', + approvedMemberToast: (displayName) => `${displayName} подтверждён.` + }, + anonymousFeedback: { + title: 'Анонимное сообщение по дому', + cancelButton: 'Отменить', + unableToStart: 'Сейчас не удалось начать анонимное сообщение.', + prompt: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».', + unableToIdentifyMessage: 'Не удалось определить это сообщение для анонимной отправки.', + notMember: 'Вы не являетесь участником этого дома.', + multipleHouseholds: + 'Вы состоите в нескольких домах. Откройте нужный дом из его группы, пока выбор дома ещё не добавлен.', + feedbackTopicMissing: + 'Для вашего дома ещё не настроен анонимный топик. Попросите админа выполнить /bind_feedback_topic.', + duplicate: 'Это анонимное сообщение уже было обработано.', + delivered: 'Анонимное сообщение отправлено.', + savedButPostFailed: + 'Анонимное сообщение сохранено, но публикация не удалась. Попробуйте позже.', + nothingToCancel: 'Сейчас нечего отменять.', + cancelled: 'Отменено.', + cancelledMessage: 'Анонимное сообщение отменено.', + useInPrivateChat: 'Используйте /anon в личном чате с ботом.', + useThisInPrivateChat: 'Используйте это в личном чате с ботом.', + tooShort: 'Анонимное сообщение слишком короткое. Добавьте немного деталей.', + tooLong: 'Анонимное сообщение слишком длинное. Ограничьтесь 500 символами.', + cooldown: (retryDelay) => + `Сейчас действует пауза на анонимные сообщения. Следующее сообщение можно отправить ${retryDelay}.`, + dailyCap: (retryDelay) => + `Достигнут дневной лимит анонимных сообщений. Следующее сообщение можно отправить ${retryDelay}.`, + blocklisted: 'Сообщение отклонено модерацией. Перепишите его спокойнее и без агрессии.', + submitFailed: 'Не удалось отправить анонимное сообщение.', + keepPromptSuffix: 'Отправьте исправленный текст или нажмите «Отменить».', + retryNow: 'сейчас', + retryInLessThanMinute: 'меньше чем через минуту', + retryIn: (parts) => `через ${parts}`, + day: (count) => `${count} ${count === 1 ? 'день' : count < 5 ? 'дня' : 'дней'}`, + hour: (count) => `${count} ${count === 1 ? 'час' : count < 5 ? 'часа' : 'часов'}`, + minute: (count) => `${count} ${count === 1 ? 'минуту' : count < 5 ? 'минуты' : 'минут'}` + }, + finance: { + useInGroup: 'Используйте эту команду внутри группы дома.', + householdNotConfigured: 'Для этого чата дом ещё не настроен. Сначала выполните /setup.', + unableToIdentifySender: 'Не удалось определить отправителя для этой команды.', + notMember: 'Вы не являетесь участником этого дома.', + adminOnly: 'Эту команду могут использовать только админы дома.', + cycleOpenUsage: 'Использование: /cycle_open [USD|GEL]', + cycleOpened: (period, currency) => `Период открыт: ${period} (${currency})`, + cycleOpenFailed: (message) => `Не удалось открыть период: ${message}`, + noCycleToClose: 'Не найден период для закрытия.', + cycleClosed: (period) => `Период закрыт: ${period}`, + cycleCloseFailed: (message) => `Не удалось закрыть период: ${message}`, + rentSetUsage: 'Использование: /rent_set [USD|GEL] [YYYY-MM]', + rentNoPeriod: 'Период не указан и открытый цикл не найден.', + rentSaved: (amount, currency, period) => + `Правило аренды сохранено: ${amount} ${currency}, начиная с ${period}`, + rentSaveFailed: (message) => `Не удалось сохранить правило аренды: ${message}`, + utilityAddUsage: 'Использование: /utility_add [USD|GEL]', + utilityNoOpenCycle: 'Открытый период не найден. Сначала выполните /cycle_open.', + utilityAdded: (name, amount, currency, period) => + `Коммунальный счёт добавлен: ${name} ${amount} ${currency} за ${period}`, + utilityAddFailed: (message) => `Не удалось добавить коммунальный счёт: ${message}`, + noStatementCycle: 'Для выписки период не найден.', + statementTitle: (period) => `Выписка за ${period}`, + statementLine: (displayName, amount, currency) => `- ${displayName}: ${amount} ${currency}`, + statementTotal: (amount, currency) => `Итого: ${amount} ${currency}`, + statementFailed: (message) => `Не удалось построить выписку: ${message}` + }, + purchase: { + sharedPurchaseFallback: 'общая покупка', + recorded: (summary) => `Покупка сохранена: ${summary}`, + savedForReview: (summary) => `Сохранено на проверку: ${summary}`, + parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.' + } +} diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts new file mode 100644 index 0000000..37d6f39 --- /dev/null +++ b/apps/bot/src/i18n/types.ts @@ -0,0 +1,147 @@ +export type BotLocale = 'en' | 'ru' + +export type TelegramCommandName = + | 'help' + | 'household_status' + | 'anon' + | 'cancel' + | 'setup' + | 'bind_purchase_topic' + | 'bind_feedback_topic' + | 'pending_members' + | 'approve_member' + +export interface BotCommandDescriptions { + help: string + household_status: string + anon: string + cancel: string + setup: string + bind_purchase_topic: string + bind_feedback_topic: string + pending_members: string + approve_member: string +} + +export interface PendingMemberSummary { + telegramUserId: string + displayName: string + username?: string | null +} + +export interface BotTranslationCatalog { + localeName: string + commands: BotCommandDescriptions + help: { + intro: string + privateChatHeading: string + groupAdminsHeading: string + } + bot: { + householdStatusPending: string + } + common: { + unableToIdentifySender: string + useHelp: string + } + setup: { + onlyTelegramAdmins: string + useSetupInGroup: string + onlyTelegramAdminsBindTopics: string + householdNotConfigured: string + useCommandInTopic: string + onlyHouseholdAdmins: string + pendingNotFound: string + pendingMembersHeading: (householdName: string) => string + pendingMembersHint: string + pendingMembersEmpty: (householdName: string) => string + pendingMemberLine: (member: PendingMemberSummary, index: number) => string + openMiniAppButton: string + joinHouseholdButton: string + approveMemberButton: (displayName: string) => string + telegramIdentityRequired: string + invalidJoinLink: string + joinLinkInvalidOrExpired: string + alreadyActiveMember: (displayName: string) => string + joinRequestSent: (householdName: string) => string + setupSummary: (params: { + householdName: string + telegramChatId: string + created: boolean + }) => string + useBindPurchaseTopicInGroup: string + purchaseTopicSaved: (householdName: string, threadId: string) => string + useBindFeedbackTopicInGroup: string + feedbackTopicSaved: (householdName: string, threadId: string) => string + usePendingMembersInGroup: string + useApproveMemberInGroup: string + approveMemberUsage: string + approvedMember: (displayName: string, householdName: string) => string + useButtonInGroup: string + unableToIdentifySelectedMember: string + approvedMemberToast: (displayName: string) => string + } + anonymousFeedback: { + title: string + cancelButton: string + unableToStart: string + prompt: string + unableToIdentifyMessage: string + notMember: string + multipleHouseholds: string + feedbackTopicMissing: string + duplicate: string + delivered: string + savedButPostFailed: string + nothingToCancel: string + cancelled: string + cancelledMessage: string + useInPrivateChat: string + useThisInPrivateChat: string + tooShort: string + tooLong: string + cooldown: (retryDelay: string) => string + dailyCap: (retryDelay: string) => string + blocklisted: string + submitFailed: string + keepPromptSuffix: string + retryNow: string + retryInLessThanMinute: string + retryIn: (parts: string) => string + day: (count: number) => string + hour: (count: number) => string + minute: (count: number) => string + } + finance: { + useInGroup: string + householdNotConfigured: string + unableToIdentifySender: string + notMember: string + adminOnly: string + cycleOpenUsage: string + cycleOpened: (period: string, currency: string) => string + cycleOpenFailed: (message: string) => string + noCycleToClose: string + cycleClosed: (period: string) => string + cycleCloseFailed: (message: string) => string + rentSetUsage: string + rentNoPeriod: string + rentSaved: (amount: string, currency: string, period: string) => string + rentSaveFailed: (message: string) => string + utilityAddUsage: string + utilityNoOpenCycle: string + utilityAdded: (name: string, amount: string, currency: string, period: string) => string + utilityAddFailed: (message: string) => string + noStatementCycle: string + statementTitle: (period: string) => string + statementLine: (displayName: string, amount: string, currency: string) => string + statementTotal: (amount: string, currency: string) => string + statementFailed: (message: string) => string + } + purchase: { + sharedPurchaseFallback: string + recorded: (summary: string) => string + savedForReview: (summary: string) => string + parseFailed: string + } +} diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index b638ef6..6bc3b0a 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -1,5 +1,8 @@ +import { getBotTranslations, type BotLocale } from './i18n' +import type { TelegramCommandName } from './i18n/types' + export interface TelegramCommandDefinition { - command: string + command: TelegramCommandName description: string } @@ -8,82 +11,71 @@ export interface ScopedTelegramCommands { commands: readonly TelegramCommandDefinition[] } -const DEFAULT_COMMANDS = [ - { - command: 'help', - description: 'Show command list' - }, - { - command: 'household_status', - description: 'Show current household status' - } -] as const satisfies readonly TelegramCommandDefinition[] +const DEFAULT_COMMAND_NAMES = [ + 'help', + 'household_status' +] as const satisfies readonly TelegramCommandName[] +const PRIVATE_CHAT_COMMAND_NAMES = [ + ...DEFAULT_COMMAND_NAMES, + 'anon', + 'cancel' +] as const satisfies readonly TelegramCommandName[] +const GROUP_CHAT_COMMAND_NAMES = DEFAULT_COMMAND_NAMES +const GROUP_ADMIN_COMMAND_NAMES = [ + ...GROUP_CHAT_COMMAND_NAMES, + 'setup', + 'bind_purchase_topic', + 'bind_feedback_topic', + 'pending_members', + 'approve_member' +] as const satisfies readonly TelegramCommandName[] -const PRIVATE_CHAT_COMMANDS = [ - ...DEFAULT_COMMANDS, - { - command: 'anon', - description: 'Send anonymous household feedback' - }, - { - command: 'cancel', - description: 'Cancel the current prompt' - } -] as const satisfies readonly TelegramCommandDefinition[] +function mapCommands( + locale: BotLocale, + names: readonly TelegramCommandName[] +): readonly TelegramCommandDefinition[] { + const descriptions = getBotTranslations(locale).commands -const GROUP_CHAT_COMMANDS = DEFAULT_COMMANDS + return names.map((command) => ({ + command, + description: descriptions[command] + })) +} -const GROUP_ADMIN_COMMANDS = [ - ...GROUP_CHAT_COMMANDS, - { - command: 'setup', - description: 'Register this group as a household' - }, - { - command: 'bind_purchase_topic', - description: 'Bind the current topic as purchases' - }, - { - command: 'bind_feedback_topic', - description: 'Bind the current topic as feedback' - }, - { - command: 'pending_members', - description: 'List pending household join requests' - }, - { - command: 'approve_member', - description: 'Approve a pending household member' - } -] as const satisfies readonly TelegramCommandDefinition[] - -export const TELEGRAM_COMMAND_SCOPES = [ - { - scope: 'default', - commands: DEFAULT_COMMANDS - }, - { - scope: 'all_private_chats', - commands: PRIVATE_CHAT_COMMANDS - }, - { - scope: 'all_group_chats', - commands: GROUP_CHAT_COMMANDS - }, - { - scope: 'all_chat_administrators', - commands: GROUP_ADMIN_COMMANDS - } -] as const satisfies readonly ScopedTelegramCommands[] - -export function formatTelegramHelpText(): string { +export function getTelegramCommandScopes(locale: BotLocale): readonly ScopedTelegramCommands[] { return [ - 'Household bot scaffold is live.', - 'Private chat:', - ...PRIVATE_CHAT_COMMANDS.map((command) => `/${command.command} - ${command.description}`), - 'Group admins:', - ...GROUP_ADMIN_COMMANDS.filter( - (command) => !DEFAULT_COMMANDS.some((baseCommand) => baseCommand.command === command.command) - ).map((command) => `/${command.command} - ${command.description}`) + { + scope: 'default', + commands: mapCommands(locale, DEFAULT_COMMAND_NAMES) + }, + { + scope: 'all_private_chats', + commands: mapCommands(locale, PRIVATE_CHAT_COMMAND_NAMES) + }, + { + scope: 'all_group_chats', + commands: mapCommands(locale, GROUP_CHAT_COMMAND_NAMES) + }, + { + scope: 'all_chat_administrators', + commands: mapCommands(locale, GROUP_ADMIN_COMMAND_NAMES) + } + ] +} + +export function formatTelegramHelpText(locale: BotLocale): string { + const t = getBotTranslations(locale) + const defaultCommands = new Set(DEFAULT_COMMAND_NAMES) + const privateCommands = mapCommands(locale, PRIVATE_CHAT_COMMAND_NAMES) + const adminCommands = mapCommands(locale, GROUP_ADMIN_COMMAND_NAMES).filter( + (command) => !defaultCommands.has(command.command) + ) + + return [ + t.help.intro, + t.help.privateChatHeading, + ...privateCommands.map((command) => `/${command.command} - ${command.description}`), + t.help.groupAdminsHeading, + ...adminCommands.map((command) => `/${command.command} - ${command.description}`) ].join('\n') } diff --git a/scripts/ops/telegram-commands.ts b/scripts/ops/telegram-commands.ts index fe7a4a7..9d192c8 100644 --- a/scripts/ops/telegram-commands.ts +++ b/scripts/ops/telegram-commands.ts @@ -1,11 +1,37 @@ -import { TELEGRAM_COMMAND_SCOPES } from '../../apps/bot/src/telegram-commands' +import { + getTelegramCommandScopes, + type ScopedTelegramCommands +} from '../../apps/bot/src/telegram-commands' +import type { BotLocale } from '../../apps/bot/src/i18n' type CommandsCommand = 'info' | 'set' | 'delete' +type CommandLanguageTarget = 'default' | BotLocale + interface TelegramScopePayload { type: 'default' | 'all_private_chats' | 'all_group_chats' | 'all_chat_administrators' } +interface CommandLanguageConfig { + target: CommandLanguageTarget + locale: BotLocale +} + +const COMMAND_LANGUAGES: readonly CommandLanguageConfig[] = [ + { + target: 'default', + locale: 'en' + }, + { + target: 'en', + locale: 'en' + }, + { + target: 'ru', + locale: 'ru' + } +] + function requireEnv(name: string): string { const value = process.env[name]?.trim() if (!value) { @@ -50,10 +76,19 @@ function appendScope(params: URLSearchParams, scope: TelegramScopePayload): void params.set('scope', JSON.stringify(scope)) } -async function setCommands(botToken: string): Promise { - const languageCode = process.env.TELEGRAM_COMMANDS_LANGUAGE_CODE?.trim() +function appendLanguageCode(params: URLSearchParams, target: CommandLanguageTarget): void { + if (target !== 'default') { + params.set('language_code', target) + } +} - for (const scopeConfig of TELEGRAM_COMMAND_SCOPES) { +async function setCommandsForLanguage( + botToken: string, + language: CommandLanguageConfig +): Promise { + const scopes = getTelegramCommandScopes(language.locale) + + for (const scopeConfig of scopes) { const params = new URLSearchParams({ commands: JSON.stringify(scopeConfig.commands) }) @@ -61,78 +96,86 @@ async function setCommands(botToken: string): Promise { appendScope(params, { type: scopeConfig.scope }) - - if (languageCode) { - params.set('language_code', languageCode) - } + appendLanguageCode(params, language.target) await telegramRequest(botToken, 'setMyCommands', params) } - console.log( - JSON.stringify( - { - ok: true, - scopes: TELEGRAM_COMMAND_SCOPES.map((scope) => ({ - scope: scope.scope, - commandCount: scope.commands.length - })) - }, - null, - 2 - ) - ) + return scopes +} + +async function setCommands(botToken: string): Promise { + const results = [] + + for (const language of COMMAND_LANGUAGES) { + const scopes = await setCommandsForLanguage(botToken, language) + results.push({ + language: language.target, + locale: language.locale, + scopes: scopes.map((scope) => ({ + scope: scope.scope, + commandCount: scope.commands.length + })) + }) + } + + console.log(JSON.stringify({ ok: true, results }, null, 2)) } async function deleteCommands(botToken: string): Promise { - const languageCode = process.env.TELEGRAM_COMMANDS_LANGUAGE_CODE?.trim() + const deletedScopes = [] - for (const scopeConfig of TELEGRAM_COMMAND_SCOPES) { - const params = new URLSearchParams() - appendScope(params, { - type: scopeConfig.scope - }) + for (const language of COMMAND_LANGUAGES) { + for (const scopeConfig of getTelegramCommandScopes(language.locale)) { + const params = new URLSearchParams() + appendScope(params, { + type: scopeConfig.scope + }) + appendLanguageCode(params, language.target) - if (languageCode) { - params.set('language_code', languageCode) + await telegramRequest(botToken, 'deleteMyCommands', params) } - await telegramRequest(botToken, 'deleteMyCommands', params) + deletedScopes.push({ + language: language.target, + scopes: getTelegramCommandScopes(language.locale).map((scope) => scope.scope) + }) } - console.log( - JSON.stringify( - { - ok: true, - deletedScopes: TELEGRAM_COMMAND_SCOPES.map((scope) => scope.scope) - }, - null, - 2 - ) - ) + console.log(JSON.stringify({ ok: true, deletedScopes }, null, 2)) } async function getCommands(botToken: string): Promise { - const languageCode = process.env.TELEGRAM_COMMANDS_LANGUAGE_CODE?.trim() const result: Array<{ - scope: string - commands: unknown + language: CommandLanguageTarget + locale: BotLocale + scopes: Array<{ + scope: string + commands: unknown + }> }> = [] - for (const scopeConfig of TELEGRAM_COMMAND_SCOPES) { - const params = new URLSearchParams() - appendScope(params, { - type: scopeConfig.scope - }) + for (const language of COMMAND_LANGUAGES) { + const scopes = [] - if (languageCode) { - params.set('language_code', languageCode) + for (const scopeConfig of getTelegramCommandScopes(language.locale)) { + const params = new URLSearchParams() + appendScope(params, { + type: scopeConfig.scope + }) + appendLanguageCode(params, language.target) + + const commands = await telegramRequest(botToken, 'getMyCommands', params) + scopes.push({ + scope: scopeConfig.scope, + commands + }) } - const commands = await telegramRequest(botToken, 'getMyCommands', params) result.push({ - scope: scopeConfig.scope, - commands + language: language.target, + locale: language.locale, + scopes }) }