diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index 5fc6ab0..7d8b5ee 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import type { HouseholdConfigurationRepository } from '@household/ports' import { createTelegramBot } from './bot' @@ -30,9 +31,112 @@ function helpUpdate(languageCode: string) { } } +function groupHelpUpdate(languageCode: string) { + return { + update_id: 9002, + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: languageCode + }, + message_thread_id: 7, + text: '/help', + entities: [ + { + offset: 0, + length: 5, + type: 'bot_command' + } + ] + } + } +} + +function createRepository(isAdmin = false): HouseholdConfigurationRepository { + return { + registerTelegramHouseholdChat: async () => { + throw new Error('not implemented') + }, + getTelegramHouseholdChat: async () => null, + getHouseholdChatByHouseholdId: async () => null, + bindHouseholdTopic: async () => { + throw new Error('not implemented') + }, + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], + upsertHouseholdJoinToken: async () => { + throw new Error('not implemented') + }, + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async () => { + throw new Error('not implemented') + }, + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async () => { + throw new Error('not implemented') + }, + getHouseholdMember: async () => null, + listHouseholdMembers: async () => [], + getHouseholdBillingSettings: async () => ({ + householdId: 'household-1', + settlementCurrency: 'GEL', + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async () => { + throw new Error('not implemented') + }, + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async () => { + throw new Error('not implemented') + }, + listHouseholdMembersByTelegramUserId: async () => + isAdmin + ? [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: 'ru', + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ] + : [], + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async () => { + throw new Error('not implemented') + }, + updateMemberPreferredLocale: async () => null, + promoteHouseholdAdmin: async () => null, + updateHouseholdMemberRentShareWeight: async () => null + } +} + describe('createTelegramBot i18n', () => { test('replies with Russian help text for Russian users', async () => { - const bot = createTelegramBot('000000:test-token') + const bot = createTelegramBot('000000:test-token', undefined, createRepository(false)) const calls: Array<{ method: string; payload: unknown }> = [] bot.botInfo = { @@ -75,5 +179,104 @@ describe('createTelegramBot i18n', () => { const payload = calls[0]?.payload as { text?: string } | undefined expect(payload?.text).toContain('Бот для дома подключен.') expect(payload?.text).toContain('/anon - Отправить анонимное сообщение по дому') + expect(payload?.text).not.toContain('/setup') + }) + + test('shows admin commands in private help for household admins', async () => { + const bot = createTelegramBot('000000:test-token', undefined, createRepository(true)) + 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) + + const payload = calls[0]?.payload as { text?: string } | undefined + expect(payload?.text).toContain('/setup - Подключить эту группу как дом') + }) + + test('shows admin commands in group help for Telegram group admins', async () => { + const bot = createTelegramBot('000000:test-token', undefined, createRepository(false)) + 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 }) + + if (method === 'getChatMember') { + return { + ok: true, + result: { + status: 'administrator', + user: { + id: 123456, + is_bot: false, + first_name: 'Stan' + } + } + } as never + } + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + await bot.handleUpdate(groupHelpUpdate('en') as never) + + const payload = calls.find((call) => call.method === 'sendMessage')?.payload as + | { text?: string } + | undefined + expect(payload?.text).toContain('/setup - Register this group as a household') + expect(payload?.text).not.toContain('/anon - Send anonymous household feedback') }) }) diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 6fd8333..1f9c2d9 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,4 +1,4 @@ -import { Bot } from 'grammy' +import { Bot, type Context } from 'grammy' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository } from '@household/ports' @@ -6,6 +6,38 @@ import { getBotTranslations } from './i18n' import { resolveReplyLocale } from './bot-locale' import { formatTelegramHelpText } from './telegram-commands' +async function shouldShowAdminCommands(options: { + ctx: Context + householdConfigurationRepository?: HouseholdConfigurationRepository +}): Promise { + const telegramUserId = options.ctx.from?.id?.toString() + if (!telegramUserId) { + return false + } + + if (options.ctx.chat?.type === 'private') { + if (!options.householdConfigurationRepository) { + return false + } + + const memberships = + await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId( + telegramUserId + ) + + return memberships.some((member) => member.isAdmin) + } + + const chatId = options.ctx.chat?.id + const userId = options.ctx.from?.id + if (!chatId || !userId) { + return false + } + + const membership = await options.ctx.api.getChatMember(chatId, userId) + return membership.status === 'administrator' || membership.status === 'creator' +} + export function createTelegramBot( token: string, logger?: Logger, @@ -18,7 +50,20 @@ export function createTelegramBot( ctx, repository: householdConfigurationRepository }) - await ctx.reply(formatTelegramHelpText(locale)) + const includeAdminCommands = await shouldShowAdminCommands({ + ctx, + ...(householdConfigurationRepository + ? { + householdConfigurationRepository + } + : {}) + }) + await ctx.reply( + formatTelegramHelpText(locale, { + includePrivateCommands: ctx.chat?.type === 'private', + includeAdminCommands + }) + ) }) bot.command('household_status', async (ctx) => { diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index 6eb0d2f..fda3feb 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -11,6 +11,11 @@ export interface ScopedTelegramCommands { commands: readonly TelegramCommandDefinition[] } +export interface TelegramHelpOptions { + includePrivateCommands?: boolean + includeAdminCommands?: boolean +} + const DEFAULT_COMMAND_NAMES = [ 'help', 'household_status' @@ -65,19 +70,38 @@ export function getTelegramCommandScopes(locale: BotLocale): readonly ScopedTele ] } -export function formatTelegramHelpText(locale: BotLocale): string { +export function formatTelegramHelpText( + locale: BotLocale, + options: TelegramHelpOptions = {} +): 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) - ) + const includePrivateCommands = options.includePrivateCommands ?? true + const includeAdminCommands = options.includeAdminCommands ?? false + const privateCommands = includePrivateCommands + ? mapCommands(locale, PRIVATE_CHAT_COMMAND_NAMES) + : [] + const adminCommands = includeAdminCommands + ? 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') + const sections = [t.help.intro] + + if (privateCommands.length > 0) { + sections.push( + t.help.privateChatHeading, + ...privateCommands.map((command) => `/${command.command} - ${command.description}`) + ) + } + + if (adminCommands.length > 0) { + sections.push( + t.help.groupAdminsHeading, + ...adminCommands.map((command) => `/${command.command} - ${command.description}`) + ) + } + + return sections.join('\n') }