diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index dddaa16..ca54221 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -5,6 +5,18 @@ import type { HouseholdOnboardingService, HouseholdSetupService } from '@household/application' +import { createHouseholdSetupService } from '@household/application' +import { nowInstant, Temporal } from '@household/domain' +import type { + HouseholdConfigurationRepository, + HouseholdJoinTokenRecord, + HouseholdMemberRecord, + HouseholdPendingMemberRecord, + HouseholdTelegramChatRecord, + HouseholdTopicBindingRecord, + TelegramPendingActionRecord, + TelegramPendingActionRepository +} from '@household/ports' import { createTelegramBot } from './bot' import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup' @@ -43,7 +55,7 @@ function startUpdate(text: string, languageCode?: string) { } } -function createHouseholdSetupService(): HouseholdSetupService { +function createRejectedHouseholdSetupService(): HouseholdSetupService { return { async setupGroupChat() { return { @@ -77,6 +89,392 @@ function createHouseholdAdminService(): HouseholdAdminService { } } +function groupCommandUpdate(text: string) { + const commandToken = text.split(' ')[0] ?? text + + return { + update_id: 3001, + message: { + message_id: 81, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori House' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: 'en' + }, + text, + entities: [ + { + offset: 0, + length: commandToken.length, + type: 'bot_command' + } + ] + } + } +} + +function groupCallbackUpdate(data: string) { + return { + update_id: 3002, + callback_query: { + id: 'callback-1', + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: 'en' + }, + chat_instance: 'group-instance-1', + data, + message: { + message_id: 91, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori House' + }, + text: 'placeholder' + } + } + } +} + +function topicMessageUpdate(text: string, threadId: number) { + return { + update_id: 3003, + message: { + message_id: 92, + date: Math.floor(Date.now() / 1000), + is_topic_message: true, + message_thread_id: threadId, + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori House' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: 'en' + }, + text + } + } +} + +function createPromptRepository(): TelegramPendingActionRepository { + const store = new Map() + + return { + async upsertPendingAction(input) { + const record = { + ...input, + payload: { + ...input.payload + } + } + store.set(`${input.telegramChatId}:${input.telegramUserId}`, record) + return record + }, + async getPendingAction(telegramChatId, telegramUserId) { + const key = `${telegramChatId}:${telegramUserId}` + const record = store.get(key) + if (!record) { + return null + } + + if (record.expiresAt && Temporal.Instant.compare(record.expiresAt, nowInstant()) <= 0) { + store.delete(key) + return null + } + + return { + ...record, + payload: { + ...record.payload + } + } + }, + async clearPendingAction(telegramChatId, telegramUserId) { + store.delete(`${telegramChatId}:${telegramUserId}`) + } + } +} + +function createHouseholdConfigurationRepository(): HouseholdConfigurationRepository { + const households = new Map() + const bindings = new Map() + const joinTokens = new Map() + const pendingMembers = new Map() + const members = new Map() + + return { + async registerTelegramHouseholdChat(input) { + const existing = households.get(input.telegramChatId) + if (existing) { + const next = { + ...existing, + telegramChatType: input.telegramChatType, + title: input.title?.trim() || existing.title + } + households.set(input.telegramChatId, next) + return { + status: 'existing', + household: next + } + } + + const created: HouseholdTelegramChatRecord = { + householdId: 'household-1', + householdName: input.householdName, + telegramChatId: input.telegramChatId, + telegramChatType: input.telegramChatType, + title: input.title?.trim() || null, + defaultLocale: 'en' + } + households.set(input.telegramChatId, created) + + return { + status: 'created', + household: created + } + }, + async getTelegramHouseholdChat(telegramChatId) { + return households.get(telegramChatId) ?? null + }, + async getHouseholdChatByHouseholdId(householdId) { + return [...households.values()].find((entry) => entry.householdId === householdId) ?? null + }, + async bindHouseholdTopic(input) { + const next: HouseholdTopicBindingRecord = { + householdId: input.householdId, + role: input.role, + telegramThreadId: input.telegramThreadId, + topicName: input.topicName?.trim() || null + } + const existing = bindings.get(input.householdId) ?? [] + bindings.set( + input.householdId, + [...existing.filter((entry) => entry.role !== input.role), next].sort((left, right) => + left.role.localeCompare(right.role) + ) + ) + return next + }, + async getHouseholdTopicBinding(householdId, role) { + return bindings.get(householdId)?.find((entry) => entry.role === role) ?? null + }, + async findHouseholdTopicByTelegramContext(input) { + const household = households.get(input.telegramChatId) + if (!household) { + return null + } + + return ( + bindings + .get(household.householdId) + ?.find((entry) => entry.telegramThreadId === input.telegramThreadId) ?? null + ) + }, + async listHouseholdTopicBindings(householdId) { + return bindings.get(householdId) ?? [] + }, + async listReminderTargets() { + return [] + }, + async upsertHouseholdJoinToken(input) { + const household = [...households.values()].find( + (entry) => entry.householdId === input.householdId + ) + if (!household) { + throw new Error('Missing household') + } + + const record: HouseholdJoinTokenRecord = { + householdId: household.householdId, + householdName: household.householdName, + token: input.token, + createdByTelegramUserId: input.createdByTelegramUserId ?? null + } + joinTokens.set(household.householdId, record) + return record + }, + async getHouseholdJoinToken(householdId) { + return joinTokens.get(householdId) ?? null + }, + async getHouseholdByJoinToken(token) { + const record = [...joinTokens.values()].find((entry) => entry.token === token) + if (!record) { + return null + } + return ( + [...households.values()].find((entry) => entry.householdId === record.householdId) ?? null + ) + }, + async upsertPendingHouseholdMember(input) { + const household = [...households.values()].find( + (entry) => entry.householdId === input.householdId + ) + if (!household) { + throw new Error('Missing household') + } + + const record: HouseholdPendingMemberRecord = { + householdId: household.householdId, + householdName: household.householdName, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + username: input.username?.trim() || null, + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale + } + pendingMembers.set(`${input.householdId}:${input.telegramUserId}`, record) + return record + }, + async getPendingHouseholdMember(householdId, telegramUserId) { + return pendingMembers.get(`${householdId}:${telegramUserId}`) ?? null + }, + async findPendingHouseholdMemberByTelegramUserId(telegramUserId) { + return ( + [...pendingMembers.values()].find((entry) => entry.telegramUserId === telegramUserId) ?? + null + ) + }, + async ensureHouseholdMember(input) { + const key = `${input.householdId}:${input.telegramUserId}` + const existing = members.get(key) + const household = + [...households.values()].find((entry) => entry.householdId === input.householdId) ?? null + if (!household) { + throw new Error('Missing household') + } + + const next: HouseholdMemberRecord = { + id: existing?.id ?? `member-${input.telegramUserId}`, + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + preferredLocale: input.preferredLocale ?? existing?.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, + rentShareWeight: input.rentShareWeight ?? existing?.rentShareWeight ?? 1, + isAdmin: input.isAdmin === true || existing?.isAdmin === true + } + members.set(key, next) + return next + }, + async getHouseholdMember(householdId, telegramUserId) { + return members.get(`${householdId}:${telegramUserId}`) ?? null + }, + async listHouseholdMembers(householdId) { + return [...members.values()].filter((entry) => entry.householdId === householdId) + }, + async getHouseholdBillingSettings(householdId) { + return { + householdId, + settlementCurrency: 'GEL', + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + } + }, + async updateHouseholdBillingSettings(input) { + return { + householdId: input.householdId, + settlementCurrency: input.settlementCurrency ?? 'GEL', + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + } + }, + async listHouseholdUtilityCategories() { + return [] + }, + async upsertHouseholdUtilityCategory(input) { + return { + id: input.slug ?? 'utility-1', + householdId: input.householdId, + slug: input.slug ?? 'utility', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + } + }, + async listHouseholdMembersByTelegramUserId(telegramUserId) { + return [...members.values()].filter((entry) => entry.telegramUserId === telegramUserId) + }, + async listPendingHouseholdMembers(householdId) { + return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId) + }, + async approvePendingHouseholdMember(input) { + const key = `${input.householdId}:${input.telegramUserId}` + const pending = pendingMembers.get(key) + if (!pending) { + return null + } + + pendingMembers.delete(key) + const member: HouseholdMemberRecord = { + id: `member-${pending.telegramUserId}`, + householdId: pending.householdId, + telegramUserId: pending.telegramUserId, + displayName: pending.displayName, + preferredLocale: null, + householdDefaultLocale: pending.householdDefaultLocale, + rentShareWeight: 1, + isAdmin: input.isAdmin === true + } + members.set(key, member) + return member + }, + async updateHouseholdDefaultLocale(householdId, locale) { + const household = [...households.values()].find((entry) => entry.householdId === householdId) + if (!household) { + throw new Error('Missing household') + } + + const next = { + ...household, + defaultLocale: locale + } + households.set(next.telegramChatId, next) + return next + }, + async updateMemberPreferredLocale(householdId, telegramUserId, locale) { + const key = `${householdId}:${telegramUserId}` + const member = members.get(key) + return member + ? { + ...member, + preferredLocale: locale + } + : null + }, + async promoteHouseholdAdmin() { + return null + }, + async updateHouseholdMemberRentShareWeight() { + return null + } + } +} + describe('buildJoinMiniAppUrl', () => { test('adds join token and bot username query parameters', () => { const url = buildJoinMiniAppUrl( @@ -156,7 +554,7 @@ describe('registerHouseholdSetupCommands', () => { registerHouseholdSetupCommands({ bot, - householdSetupService: createHouseholdSetupService(), + householdSetupService: createRejectedHouseholdSetupService(), householdOnboardingService, householdAdminService: createHouseholdAdminService(), miniAppUrl: 'https://miniapp.example.app' @@ -246,7 +644,7 @@ describe('registerHouseholdSetupCommands', () => { registerHouseholdSetupCommands({ bot, - householdSetupService: createHouseholdSetupService(), + householdSetupService: createRejectedHouseholdSetupService(), householdOnboardingService, householdAdminService: createHouseholdAdminService(), miniAppUrl: 'https://miniapp.example.app' @@ -271,4 +669,395 @@ describe('registerHouseholdSetupCommands', () => { } }) }) + + test('shows setup checklist with create and bind buttons for missing topics', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const repository = createHouseholdConfigurationRepository() + + 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: true + } + + 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 + } + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } 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', + defaultLocale: 'en' + } + } + } + } + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(repository), + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository: createPromptRepository() + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + + expect(calls).toHaveLength(2) + const sendPayload = calls[1]?.payload as { + chat_id?: number + text?: string + reply_markup?: unknown + } + + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123456 + } + }) + expect(sendPayload.text).toContain('Household created: Kojori House') + expect(sendPayload.text).toContain('- purchases: not configured') + expect(sendPayload.text).toContain('- payments: not configured') + expect(sendPayload.reply_markup).toMatchObject({ + inline_keyboard: expect.arrayContaining([ + [ + { + text: 'Join household', + url: 'https://t.me/household_test_bot?start=join_join-token' + } + ], + [ + { + text: 'Create purchases', + callback_data: 'setup_topic:create:purchase' + }, + { + text: 'Bind purchases', + callback_data: 'setup_topic:bind:purchase' + } + ] + ]) + }) + }) + + test('creates and binds a missing setup topic from callback', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const repository = createHouseholdConfigurationRepository() + const promptRepository = createPromptRepository() + 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', + defaultLocale: 'en' + } + } + } + } + + 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: true + } + + 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 + } + + if (method === 'createForumTopic') { + return { + ok: true, + result: { + name: 'Purchases', + icon_color: 7322096, + message_thread_id: 77 + } + } as never + } + + return { + ok: true, + result: + method === 'editMessageText' + ? { + message_id: 91, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + : true + } as never + }) + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(repository), + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + calls.length = 0 + + await bot.handleUpdate(groupCallbackUpdate('setup_topic:create:purchase') as never) + + expect(calls[1]).toMatchObject({ + method: 'createForumTopic', + payload: { + chat_id: -100123456, + name: 'Shared purchases' + } + }) + expect(calls[2]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'purchases topic created and bound: Shared purchases.' + } + }) + expect(calls[3]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: -100123456, + message_id: 91, + text: expect.stringContaining('- purchases: bound to Shared purchases') + } + }) + + expect(await repository.getHouseholdTopicBinding('household-1', 'purchase')).toMatchObject({ + telegramThreadId: '77', + topicName: 'Shared purchases' + }) + }) + + test('arms manual setup topic binding and consumes the next topic message', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const repository = createHouseholdConfigurationRepository() + const promptRepository = createPromptRepository() + 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', + defaultLocale: 'en' + } + } + } + } + + 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: true + } + + 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 + } + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(repository), + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + calls.length = 0 + + await bot.handleUpdate(groupCallbackUpdate('setup_topic:bind:payments') as never) + + expect(calls[1]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Binding mode is on for payments. Open the target topic and send any message there within 10 minutes.' + } + }) + expect(await promptRepository.getPendingAction('-100123456', '123456')).toMatchObject({ + action: 'setup_topic_binding', + payload: { + role: 'payments' + } + }) + + calls.length = 0 + await bot.handleUpdate(topicMessageUpdate('hello from payments', 444) as never) + + expect(calls).toHaveLength(2) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123456, + message_thread_id: 444, + text: 'Payments topic saved for Kojori House (thread 444).' + } + }) + expect(await promptRepository.getPendingAction('-100123456', '123456')).toBeNull() + expect(await repository.getHouseholdTopicBinding('household-1', 'payments')).toMatchObject({ + telegramThreadId: '444' + }) + }) }) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 1fdabd4..9dcfc97 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -4,14 +4,31 @@ import type { HouseholdSetupService, HouseholdMiniAppAccess } from '@household/application' +import { nowInstant } from '@household/domain' import type { Logger } from '@household/observability' -import type { HouseholdConfigurationRepository } from '@household/ports' +import type { + HouseholdConfigurationRepository, + HouseholdTelegramChatRecord, + HouseholdTopicBindingRecord, + HouseholdTopicRole, + TelegramPendingActionRepository +} from '@household/ports' import type { Bot, Context } from 'grammy' import { getBotTranslations, type BotLocale } from './i18n' import { resolveReplyLocale } from './bot-locale' const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' +const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:' +const SETUP_BIND_TOPIC_CALLBACK_PREFIX = 'setup_topic:bind:' +const SETUP_BIND_TOPIC_ACTION = 'setup_topic_binding' as const +const SETUP_BIND_TOPIC_TTL_MS = 10 * 60 * 1000 +const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [ + 'purchase', + 'feedback', + 'reminders', + 'payments' +] function commandArgText(ctx: Context): string { return typeof ctx.match === 'string' ? ctx.match.trim() : '' @@ -28,6 +45,11 @@ function isTopicMessage(ctx: Context): boolean { return !!message && 'is_topic_message' in message && message.is_topic_message === true } +function isCommandMessage(ctx: Context): boolean { + const text = ctx.msg?.text + return typeof text === 'string' && text.trimStart().startsWith('/') +} + async function isGroupAdmin(ctx: Context): Promise { if (!ctx.chat || !ctx.from) { return false @@ -164,6 +186,136 @@ function pendingMembersReply( } as const } +function topicBindingDisplay(binding: HouseholdTopicBindingRecord): string { + return binding.topicName?.trim() || `thread ${binding.telegramThreadId}` +} + +function setupTopicRoleLabel(locale: BotLocale, role: HouseholdTopicRole): string { + return getBotTranslations(locale).setup.setupTopicBindRoleName(role) +} + +function setupSuggestedTopicName(locale: BotLocale, role: HouseholdTopicRole): string { + return getBotTranslations(locale).setup.setupTopicSuggestedName(role) +} + +function setupKeyboard(input: { + locale: BotLocale + joinDeepLink: string | null + bindings: readonly HouseholdTopicBindingRecord[] +}) { + const t = getBotTranslations(input.locale).setup + const configuredRoles = new Set(input.bindings.map((binding) => binding.role)) + const rows: Array< + Array< + | { + text: string + url: string + } + | { + text: string + callback_data: string + } + > + > = [] + + if (input.joinDeepLink) { + rows.push([ + { + text: t.joinHouseholdButton, + url: input.joinDeepLink + } + ]) + } + + for (const role of HOUSEHOLD_TOPIC_ROLE_ORDER) { + if (configuredRoles.has(role)) { + continue + } + + rows.push([ + { + text: t.setupTopicCreateButton(setupTopicRoleLabel(input.locale, role)), + callback_data: `${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}${role}` + }, + { + text: t.setupTopicBindButton(setupTopicRoleLabel(input.locale, role)), + callback_data: `${SETUP_BIND_TOPIC_CALLBACK_PREFIX}${role}` + } + ]) + } + + return rows.length > 0 + ? { + reply_markup: { + inline_keyboard: rows + } + } + : {} +} + +function setupTopicChecklist(input: { + locale: BotLocale + bindings: readonly HouseholdTopicBindingRecord[] +}): string { + const t = getBotTranslations(input.locale).setup + const bindingByRole = new Map(input.bindings.map((binding) => [binding.role, binding])) + + return [ + t.setupTopicsHeading, + ...HOUSEHOLD_TOPIC_ROLE_ORDER.map((role) => { + const binding = bindingByRole.get(role) + const roleLabel = setupTopicRoleLabel(input.locale, role) + return binding + ? t.setupTopicBound(roleLabel, topicBindingDisplay(binding)) + : t.setupTopicMissing(roleLabel) + }) + ].join('\n') +} + +function setupReply(input: { + locale: BotLocale + household: HouseholdTelegramChatRecord + created: boolean + joinDeepLink: string | null + bindings: readonly HouseholdTopicBindingRecord[] +}) { + const t = getBotTranslations(input.locale).setup + return { + text: [ + t.setupSummary({ + householdName: input.household.householdName, + telegramChatId: input.household.telegramChatId, + created: input.created + }), + setupTopicChecklist({ + locale: input.locale, + bindings: input.bindings + }) + ].join('\n\n'), + ...setupKeyboard({ + locale: input.locale, + joinDeepLink: input.joinDeepLink, + bindings: input.bindings + }) + } +} + +function isHouseholdTopicRole(value: string): value is HouseholdTopicRole { + return ( + value === 'purchase' || value === 'feedback' || value === 'reminders' || value === 'payments' + ) +} + +function parseSetupBindPayload(payload: Record): { + role: HouseholdTopicRole +} | null { + return typeof payload.role === 'string' && isHouseholdTopicRole(payload.role) + ? { + role: payload.role + } + : null +} + export function buildJoinMiniAppUrl( miniAppUrl: string | undefined, botUsername: string | undefined, @@ -216,10 +368,45 @@ export function registerHouseholdSetupCommands(options: { householdSetupService: HouseholdSetupService householdOnboardingService: HouseholdOnboardingService householdAdminService: HouseholdAdminService + promptRepository?: TelegramPendingActionRepository householdConfigurationRepository?: HouseholdConfigurationRepository miniAppUrl?: string logger?: Logger }): void { + async function buildSetupReplyForHousehold(input: { + ctx: Context + locale: BotLocale + household: HouseholdTelegramChatRecord + created: boolean + }) { + const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({ + householdId: input.household.householdId, + ...(input.ctx.from?.id + ? { + actorTelegramUserId: input.ctx.from.id.toString() + } + : {}) + }) + + const joinDeepLink = input.ctx.me.username + ? `https://t.me/${input.ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}` + : null + + const bindings = options.householdConfigurationRepository + ? await options.householdConfigurationRepository.listHouseholdTopicBindings( + input.household.householdId + ) + : [] + + return setupReply({ + locale: input.locale, + household: input.household, + created: input.created, + joinDeepLink, + bindings + }) + } + async function handleBindTopicCommand( ctx: Context, role: 'purchase' | 'feedback' | 'reminders' | 'payments' @@ -277,6 +464,67 @@ export function registerHouseholdSetupCommands(options: { ) } + if (options.promptRepository) { + const promptRepository = options.promptRepository + + options.bot.on('message', async (ctx, next) => { + if (!isGroupChat(ctx) || !isTopicMessage(ctx) || isCommandMessage(ctx)) { + await next() + return + } + + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat?.id?.toString() + const telegramThreadId = + ctx.msg && 'message_thread_id' in ctx.msg ? ctx.msg.message_thread_id?.toString() : null + + if (!telegramUserId || !telegramChatId || !telegramThreadId) { + await next() + return + } + + const pending = await promptRepository.getPendingAction(telegramChatId, telegramUserId) + if (pending?.action !== SETUP_BIND_TOPIC_ACTION) { + await next() + return + } + + const payload = parseSetupBindPayload(pending.payload) + if (!payload) { + await promptRepository.clearPendingAction(telegramChatId, telegramUserId) + await next() + return + } + + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const result = await options.householdSetupService.bindTopic({ + actorIsAdmin: await isGroupAdmin(ctx), + telegramChatId, + telegramThreadId, + role: payload.role + }) + + await promptRepository.clearPendingAction(telegramChatId, telegramUserId) + + if (result.status === 'rejected') { + await ctx.reply(bindRejectionMessage(locale, result.reason)) + return + } + + await ctx.reply( + bindTopicSuccessMessage( + locale, + payload.role, + result.household.householdName, + result.binding.telegramThreadId + ) + ) + }) + } + options.bot.command('start', async (ctx) => { const fallbackLocale = await resolveReplyLocale({ ctx, @@ -411,40 +659,13 @@ export function registerHouseholdSetupCommands(options: { 'Household group registered' ) - const action = result.status === 'created' ? 'created' : 'already registered' - const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({ - householdId: result.household.householdId, - ...(ctx.from?.id - ? { - actorTelegramUserId: ctx.from.id.toString() - } - : {}) + const reply = await buildSetupReplyForHousehold({ + ctx, + locale, + household: result.household, + created: result.status === 'created' }) - - const joinDeepLink = ctx.me.username - ? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}` - : null - await ctx.reply( - t.setup.setupSummary({ - householdName: result.household.householdName, - telegramChatId: result.household.telegramChatId, - created: action === 'created' - }), - joinDeepLink - ? { - reply_markup: { - inline_keyboard: [ - [ - { - text: t.setup.joinHouseholdButton, - url: joinDeepLink - } - ] - ] - } - } - : {} - ) + await ctx.reply(reply.text, 'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {}) }) options.bot.command('bind_purchase_topic', async (ctx) => { @@ -606,6 +827,153 @@ export function registerHouseholdSetupCommands(options: { await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName)) } ) + + if (options.promptRepository) { + const promptRepository = options.promptRepository + + options.bot.callbackQuery( + new RegExp(`^${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`), + async (ctx) => { + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).setup + + if (!isGroupChat(ctx)) { + await ctx.answerCallbackQuery({ + text: t.useButtonInGroup, + show_alert: true + }) + return + } + + const role = ctx.match[1] as HouseholdTopicRole + const telegramChatId = ctx.chat.id.toString() + const actorIsAdmin = await isGroupAdmin(ctx) + const household = options.householdConfigurationRepository + ? await options.householdConfigurationRepository.getTelegramHouseholdChat(telegramChatId) + : null + + if (!actorIsAdmin) { + await ctx.answerCallbackQuery({ + text: t.onlyTelegramAdminsBindTopics, + show_alert: true + }) + return + } + + if (!household) { + await ctx.answerCallbackQuery({ + text: t.householdNotConfigured, + show_alert: true + }) + return + } + + try { + const topicName = setupSuggestedTopicName(locale, role) + const createdTopic = await ctx.api.createForumTopic(ctx.chat.id, topicName) + const result = await options.householdSetupService.bindTopic({ + actorIsAdmin, + telegramChatId, + telegramThreadId: createdTopic.message_thread_id.toString(), + role, + topicName + }) + + if (result.status === 'rejected') { + await ctx.answerCallbackQuery({ + text: bindRejectionMessage(locale, result.reason), + show_alert: true + }) + return + } + + const reply = await buildSetupReplyForHousehold({ + ctx, + locale, + household: result.household, + created: false + }) + + await ctx.answerCallbackQuery({ + text: t.setupTopicCreated(setupTopicRoleLabel(locale, role), topicName) + }) + + if (ctx.msg) { + await ctx.editMessageText( + reply.text, + 'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {} + ) + } + } catch (error) { + const message = + error instanceof Error && + /not enough rights|forbidden|admin|permission/i.test(error.message) + ? t.setupTopicCreateForbidden + : t.setupTopicCreateFailed + + await ctx.answerCallbackQuery({ + text: message, + show_alert: true + }) + } + } + ) + + options.bot.callbackQuery( + new RegExp(`^${SETUP_BIND_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`), + async (ctx) => { + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).setup + + if (!isGroupChat(ctx)) { + await ctx.answerCallbackQuery({ + text: t.useButtonInGroup, + show_alert: true + }) + return + } + + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat.id.toString() + const role = ctx.match[1] as HouseholdTopicRole + if (!telegramUserId) { + await ctx.answerCallbackQuery({ + text: t.unableToIdentifySelectedMember, + show_alert: true + }) + return + } + + if (!(await isGroupAdmin(ctx))) { + await ctx.answerCallbackQuery({ + text: t.onlyTelegramAdminsBindTopics, + show_alert: true + }) + return + } + + await promptRepository.upsertPendingAction({ + telegramUserId, + telegramChatId, + action: SETUP_BIND_TOPIC_ACTION, + payload: { + role + }, + expiresAt: nowInstant().add({ milliseconds: SETUP_BIND_TOPIC_TTL_MS }) + }) + + await ctx.answerCallbackQuery({ + text: t.setupTopicBindPending(setupTopicRoleLabel(locale, role)) + }) + } + ) + } } function localeFromAccess(access: HouseholdMiniAppAccess, fallback: BotLocale): BotLocale { diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index f455621..10741cf 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -53,9 +53,47 @@ export const enBotTranslations: BotTranslationCatalog = { [ `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. If you want dedicated reminders or payments topics, open them and run /bind_reminders_topic or /bind_payments_topic.', + 'Use the buttons below to finish topic setup. For an existing topic, tap Bind and then send any message inside that topic.', 'Members should open the bot chat from the button below and confirm the join request there.' ].join('\n'), + setupTopicsHeading: 'Topic setup:', + setupTopicBound: (role, topic) => `- ${role}: bound to ${topic}`, + setupTopicMissing: (role) => `- ${role}: not configured`, + setupTopicCreateButton: (role) => `Create ${role}`, + setupTopicBindButton: (role) => `Bind ${role}`, + setupTopicCreateFailed: + 'I could not create that topic. Check bot admin permissions and forum settings.', + setupTopicCreateForbidden: + 'I need permission to manage topics in this group before I can create one automatically.', + setupTopicCreated: (role, topicName) => `${role} topic created and bound: ${topicName}.`, + setupTopicBindPending: (role) => + `Binding mode is on for ${role}. Open the target topic and send any message there within 10 minutes.`, + setupTopicBindCancelled: 'Topic binding mode cleared.', + setupTopicBindNotAvailable: 'That topic-binding action is no longer available.', + setupTopicBindRoleName: (role) => { + switch (role) { + case 'purchase': + return 'purchases' + case 'feedback': + return 'feedback' + case 'reminders': + return 'reminders' + case 'payments': + return 'payments' + } + }, + setupTopicSuggestedName: (role) => { + switch (role) { + case 'purchase': + return 'Shared purchases' + case 'feedback': + return 'Anonymous feedback' + case 'reminders': + return 'Reminders' + case 'payments': + return 'Payments' + } + }, useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.', purchaseTopicSaved: (householdName, threadId) => `Purchase topic saved for ${householdName} (thread ${threadId}).`, diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 6f620b2..bb3c4d6 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -55,9 +55,47 @@ export const ruBotTranslations: BotTranslationCatalog = { [ `${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`, `ID чата: ${telegramChatId}`, - 'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельные топики для напоминаний или оплат, откройте их и выполните /bind_reminders_topic или /bind_payments_topic.', + 'Используйте кнопки ниже, чтобы завершить настройку топиков. Для уже существующего топика нажмите «Привязать», затем отправьте любое сообщение внутри этого топика.', 'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.' ].join('\n'), + setupTopicsHeading: 'Настройка топиков:', + setupTopicBound: (role, topic) => `- ${role}: привязан к ${topic}`, + setupTopicMissing: (role) => `- ${role}: не настроен`, + setupTopicCreateButton: (role) => `Создать ${role}`, + setupTopicBindButton: (role) => `Привязать ${role}`, + setupTopicCreateFailed: + 'Не удалось создать этот топик. Проверьте права бота и включённые форум-топики в группе.', + setupTopicCreateForbidden: + 'Мне нужны права на управление топиками в этой группе, чтобы создать его автоматически.', + setupTopicCreated: (role, topicName) => `Топик ${role} создан и привязан: ${topicName}.`, + setupTopicBindPending: (role) => + `Режим привязки включён для ${role}. Откройте нужный топик и отправьте там любое сообщение в течение 10 минут.`, + setupTopicBindCancelled: 'Режим привязки топика очищен.', + setupTopicBindNotAvailable: 'Это действие привязки топика уже недоступно.', + setupTopicBindRoleName: (role) => { + switch (role) { + case 'purchase': + return 'покупки' + case 'feedback': + return 'обратной связи' + case 'reminders': + return 'напоминаний' + case 'payments': + return 'оплат' + } + }, + setupTopicSuggestedName: (role) => { + switch (role) { + case 'purchase': + return 'Общие покупки' + case 'feedback': + return 'Анонимная обратная связь' + case 'reminders': + return 'Напоминания' + case 'payments': + return 'Оплаты' + } + }, useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.', purchaseTopicSaved: (householdName, threadId) => `Топик покупок сохранён для ${householdName} (тред ${threadId}).`, diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 8c4039d..d6f5fd6 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -73,6 +73,19 @@ export interface BotTranslationCatalog { telegramChatId: string created: boolean }) => string + setupTopicsHeading: string + setupTopicBound: (role: string, topic: string) => string + setupTopicMissing: (role: string) => string + setupTopicCreateButton: (role: string) => string + setupTopicBindButton: (role: string) => string + setupTopicCreateFailed: string + setupTopicCreateForbidden: string + setupTopicCreated: (role: string, topicName: string) => string + setupTopicBindPending: (role: string) => string + setupTopicBindCancelled: string + setupTopicBindNotAvailable: string + setupTopicBindRoleName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string + setupTopicSuggestedName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string useBindPurchaseTopicInGroup: string purchaseTopicSaved: (householdName: string, threadId: string) => string useBindFeedbackTopicInGroup: string diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 0dbf377..96c56f3 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -282,6 +282,11 @@ if (householdConfigurationRepositoryClient) { ), householdOnboardingService: householdOnboardingService!, householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + ...(telegramPendingActionRepositoryClient + ? { + promptRepository: telegramPendingActionRepositoryClient.repository + } + : {}), ...(runtime.miniAppAllowedOrigins[0] ? { miniAppUrl: runtime.miniAppAllowedOrigins[0] diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index faf9fe8..3bc18d2 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -17,6 +17,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType { return raw } + if (raw === 'setup_topic_binding') { + return raw + } + throw new Error(`Unexpected telegram pending action type: ${raw}`) } diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 90134da..06c861f 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -2,7 +2,8 @@ import type { Instant } from '@household/domain' export const TELEGRAM_PENDING_ACTION_TYPES = [ 'anonymous_feedback', - 'assistant_payment_confirmation' + 'assistant_payment_confirmation', + 'setup_topic_binding' ] as const export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]