From b6b6f9e1b84615611177f0740ccc49254af41553 Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 06:08:34 +0400 Subject: [PATCH] feat(bot): add safe group unsetup flow --- apps/bot/src/anonymous-feedback.test.ts | 14 + apps/bot/src/bot-i18n.test.ts | 1 + apps/bot/src/dm-assistant.test.ts | 12 + apps/bot/src/finance-commands.test.ts | 1 + apps/bot/src/household-setup.test.ts | 249 ++++++++++++++++++ apps/bot/src/household-setup.ts | 64 +++++ apps/bot/src/i18n/locales/en.ts | 6 + apps/bot/src/i18n/locales/ru.ts | 6 + apps/bot/src/i18n/types.ts | 6 + apps/bot/src/miniapp-admin.test.ts | 1 + apps/bot/src/miniapp-auth.test.ts | 1 + apps/bot/src/miniapp-billing.test.ts | 1 + apps/bot/src/miniapp-dashboard.test.ts | 1 + apps/bot/src/miniapp-locale.test.ts | 1 + apps/bot/src/telegram-commands.ts | 1 + .../src/household-config-repository.ts | 6 + .../src/telegram-pending-action-repository.ts | 13 + .../src/household-admin-service.test.ts | 1 + .../src/household-onboarding-service.test.ts | 1 + .../src/household-setup-service.test.ts | 55 ++++ .../src/household-setup-service.ts | 47 ++++ .../src/locale-preference-service.test.ts | 1 + .../src/miniapp-admin-service.test.ts | 1 + packages/ports/src/household-config.ts | 1 + .../ports/src/telegram-pending-actions.ts | 4 + 25 files changed, 495 insertions(+) diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 2e72b3c..ff3c87b 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -89,6 +89,19 @@ function createPromptRepository(): TelegramPendingActionRepository { }, async clearPendingAction(telegramChatId, telegramUserId) { store.delete(`${telegramChatId}:${telegramUserId}`) + }, + async clearPendingActionsForChat(telegramChatId, action) { + for (const [key, record] of store.entries()) { + if (!key.startsWith(`${telegramChatId}:`)) { + continue + } + + if (action && record.action !== action) { + continue + } + + store.delete(key) + } } } } @@ -139,6 +152,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit : null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: 'household-1', diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index 7d8b5ee..b616fb1 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -74,6 +74,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => { throw new Error('not implemented') diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 541cea8..2ea3c5e 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -106,6 +106,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => { throw new Error('not used') @@ -309,6 +310,17 @@ function createPromptRepository(): TelegramPendingActionRepository { return pending }, async clearPendingAction() { + pending = null + }, + async clearPendingActionsForChat(telegramChatId, action) { + if (!pending || pending.telegramChatId !== telegramChatId) { + return + } + + if (action && pending.action !== action) { + return + } + pending = null } } diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index 1f1d81b..91bd5c6 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -55,6 +55,7 @@ function createRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => { throw new Error('not implemented') diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index ca54221..80e6d20 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -68,6 +68,11 @@ function createRejectedHouseholdSetupService(): HouseholdSetupService { status: 'rejected', reason: 'household_not_found' } + }, + async unsetupGroupChat() { + return { + status: 'noop' + } } } } @@ -206,6 +211,19 @@ function createPromptRepository(): TelegramPendingActionRepository { }, async clearPendingAction(telegramChatId, telegramUserId) { store.delete(`${telegramChatId}:${telegramUserId}`) + }, + async clearPendingActionsForChat(telegramChatId, action) { + for (const [key, record] of store.entries()) { + if (!key.startsWith(`${telegramChatId}:`)) { + continue + } + + if (action && record.action !== action) { + continue + } + + store.delete(key) + } } } } @@ -288,6 +306,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit async listHouseholdTopicBindings(householdId) { return bindings.get(householdId) ?? [] }, + async clearHouseholdTopicBindings(householdId) { + bindings.set(householdId, []) + }, async listReminderTargets() { return [] }, @@ -1060,4 +1081,232 @@ describe('registerHouseholdSetupCommands', () => { telegramThreadId: '444' }) }) + + test('resets setup state with /unsetup and clears pending setup bindings', 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 + }) + + const householdSetupService = createHouseholdSetupService(repository) + + registerHouseholdSetupCommands({ + bot, + householdSetupService, + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + await householdSetupService.bindTopic({ + actorIsAdmin: true, + telegramChatId: '-100123456', + role: 'purchase', + telegramThreadId: '777', + topicName: 'Shared purchases' + }) + await promptRepository.upsertPendingAction({ + telegramUserId: '123456', + telegramChatId: '-100123456', + action: 'setup_topic_binding', + payload: { + role: 'payments' + }, + expiresAt: nowInstant().add({ minutes: 10 }) + }) + + calls.length = 0 + await bot.handleUpdate(groupCommandUpdate('/unsetup') as never) + + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123456, + text: 'Setup state reset for Kojori House. Run /setup again to bind topics from scratch.' + } + }) + expect(await repository.listHouseholdTopicBindings('household-1')).toEqual([]) + expect(await repository.getTelegramHouseholdChat('-100123456')).toMatchObject({ + householdId: 'household-1' + }) + expect(await promptRepository.getPendingAction('-100123456', '123456')).toBeNull() + }) + + test('treats repeated /unsetup as a safe no-op', 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 + }) + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(repository), + 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' + } + } + } + }, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository: createPromptRepository() + }) + + await bot.handleUpdate(groupCommandUpdate('/unsetup') as never) + + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123456, + text: 'Nothing to reset for this group yet. Run /setup when you are ready.' + } + }) + }) }) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 9dcfc97..4b81398 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -72,6 +72,19 @@ function setupRejectionMessage( } } +function unsetupRejectionMessage( + locale: BotLocale, + reason: 'not_admin' | 'invalid_chat_type' +): string { + const t = getBotTranslations(locale).setup + switch (reason) { + case 'not_admin': + return t.onlyTelegramAdminsUnsetup + case 'invalid_chat_type': + return t.useUnsetupInGroup + } +} + function bindRejectionMessage( locale: BotLocale, reason: 'not_admin' | 'household_not_found' | 'not_topic_message' @@ -668,6 +681,57 @@ export function registerHouseholdSetupCommands(options: { await ctx.reply(reply.text, 'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {}) }) + options.bot.command('unsetup', async (ctx) => { + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale) + + if (!isGroupChat(ctx)) { + await ctx.reply(t.setup.useUnsetupInGroup) + return + } + + const telegramChatId = ctx.chat.id.toString() + const result = await options.householdSetupService.unsetupGroupChat({ + actorIsAdmin: await isGroupAdmin(ctx), + telegramChatId, + telegramChatType: ctx.chat.type + }) + + if (result.status === 'rejected') { + await ctx.reply(unsetupRejectionMessage(locale, result.reason)) + return + } + + if (result.status === 'noop') { + await options.promptRepository?.clearPendingActionsForChat( + telegramChatId, + SETUP_BIND_TOPIC_ACTION + ) + await ctx.reply(t.setup.unsetupNoop) + return + } + + await options.promptRepository?.clearPendingActionsForChat( + telegramChatId, + SETUP_BIND_TOPIC_ACTION + ) + + options.logger?.info( + { + event: 'household_setup.chat_reset', + telegramChatId, + householdId: result.household.householdId, + actorTelegramUserId: ctx.from?.id?.toString() + }, + 'Household setup state reset' + ) + + await ctx.reply(t.setup.unsetupComplete(result.household.householdName)) + }) + options.bot.command('bind_purchase_topic', async (ctx) => { await handleBindTopicCommand(ctx, 'purchase') }) diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 10741cf..9974095 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -8,6 +8,7 @@ export const enBotTranslations: BotTranslationCatalog = { anon: 'Send anonymous household feedback', cancel: 'Cancel the current prompt', setup: 'Register this group as a household', + unsetup: 'Reset topic setup for this group', bind_purchase_topic: 'Bind the current topic as purchases', bind_feedback_topic: 'Bind the current topic as feedback', bind_reminders_topic: 'Bind the current topic as reminders', @@ -94,6 +95,11 @@ export const enBotTranslations: BotTranslationCatalog = { return 'Payments' } }, + onlyTelegramAdminsUnsetup: 'Only Telegram group admins can run /unsetup.', + useUnsetupInGroup: 'Use /unsetup inside the household group.', + unsetupComplete: (householdName) => + `Setup state reset for ${householdName}. Run /setup again to bind topics from scratch.`, + unsetupNoop: 'Nothing to reset for this group yet. Run /setup when you are ready.', 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 bb3c4d6..51422c5 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -8,6 +8,7 @@ export const ruBotTranslations: BotTranslationCatalog = { anon: 'Отправить анонимное сообщение по дому', cancel: 'Отменить текущий ввод', setup: 'Подключить эту группу как дом', + unsetup: 'Сбросить настройку топиков для этой группы', bind_purchase_topic: 'Назначить текущий топик для покупок', bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_reminders_topic: 'Назначить текущий топик для напоминаний', @@ -96,6 +97,11 @@ export const ruBotTranslations: BotTranslationCatalog = { return 'Оплаты' } }, + onlyTelegramAdminsUnsetup: 'Только админы Telegram-группы могут запускать /unsetup.', + useUnsetupInGroup: 'Используйте /unsetup внутри группы дома.', + unsetupComplete: (householdName) => + `Состояние настройки для ${householdName} сброшено. Запустите /setup ещё раз, чтобы заново привязать топики.`, + unsetupNoop: 'Для этой группы пока нечего сбрасывать. Когда будете готовы, запустите /setup.', 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 d6f5fd6..269b413 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -6,6 +6,7 @@ export type TelegramCommandName = | 'anon' | 'cancel' | 'setup' + | 'unsetup' | 'bind_purchase_topic' | 'bind_feedback_topic' | 'bind_reminders_topic' @@ -20,6 +21,7 @@ export interface BotCommandDescriptions { anon: string cancel: string setup: string + unsetup: string bind_purchase_topic: string bind_feedback_topic: string bind_reminders_topic: string @@ -86,6 +88,10 @@ export interface BotTranslationCatalog { setupTopicBindNotAvailable: string setupTopicBindRoleName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string setupTopicSuggestedName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string + onlyTelegramAdminsUnsetup: string + useUnsetupInGroup: string + unsetupComplete: (householdName: string) => string + unsetupNoop: string useBindPurchaseTopicInGroup: string purchaseTopicSaved: (householdName: string, threadId: string) => string useBindFeedbackTopicInGroup: string diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index 29b154e..26a353d 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -49,6 +49,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { topicName: 'Общие покупки' } ], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 535d79b..6c02458 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -59,6 +59,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 259511d..77b137b 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -45,6 +45,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 9dabc6f..a8a67c2 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -152,6 +152,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 9dca72f..3d2075e 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -66,6 +66,7 @@ function repository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: household.householdId, diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index 156b582..477db25 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -34,6 +34,7 @@ const GROUP_MEMBER_COMMAND_NAMES = [ const GROUP_ADMIN_COMMAND_NAMES = [ ...GROUP_MEMBER_COMMAND_NAMES, 'setup', + 'unsetup', 'bind_purchase_topic', 'bind_feedback_topic', 'bind_reminders_topic', diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 2771c70..233d650 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -503,6 +503,12 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { return rows.map(toHouseholdTopicBindingRecord) }, + async clearHouseholdTopicBindings(householdId) { + await db + .delete(schema.householdTopicBindings) + .where(eq(schema.householdTopicBindings.householdId, householdId)) + }, + async listReminderTargets() { const rows = await db .select({ diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index 3bc18d2..494045f 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -151,6 +151,19 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): { eq(schema.telegramPendingActions.telegramUserId, telegramUserId) ) ) + }, + + async clearPendingActionsForChat(telegramChatId, action) { + await db + .delete(schema.telegramPendingActions) + .where( + action + ? and( + eq(schema.telegramPendingActions.telegramChatId, telegramChatId), + eq(schema.telegramPendingActions.action, action) + ) + : eq(schema.telegramPendingActions.telegramChatId, telegramChatId) + ) } } diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index 1ca302e..120397a 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -60,6 +60,7 @@ function createRepositoryStub() { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index ed37d4f..140a8fd 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -55,6 +55,7 @@ function createRepositoryStub() { async listHouseholdTopicBindings() { return [] }, + async clearHouseholdTopicBindings() {}, async listReminderTargets() { return [] }, diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index cc326ca..ec3d874 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -93,6 +93,9 @@ function createRepositoryStub() { async listHouseholdTopicBindings(householdId) { return bindings.get(householdId) ?? [] }, + async clearHouseholdTopicBindings(householdId) { + bindings.set(householdId, []) + }, async listReminderTargets() { return [] }, @@ -514,4 +517,56 @@ describe('createHouseholdSetupService', () => { reason: 'not_topic_message' }) }) + + test('clears topic bindings when unsetup is run by a group admin', async () => { + const { repository } = createRepositoryStub() + const service = createHouseholdSetupService(repository) + const setup = await service.setupGroupChat({ + actorIsAdmin: true, + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House' + }) + + expect(setup.status).toBe('created') + if (setup.status === 'rejected') { + return + } + + await service.bindTopic({ + actorIsAdmin: true, + telegramChatId: '-100123', + role: 'purchase', + telegramThreadId: '777', + topicName: 'Shared purchases' + }) + + const result = await service.unsetupGroupChat({ + actorIsAdmin: true, + telegramChatId: '-100123', + telegramChatType: 'supergroup' + }) + + expect(result).toEqual({ + status: 'reset', + household: setup.household + }) + expect(await repository.listHouseholdTopicBindings(setup.household.householdId)).toEqual([]) + expect(await repository.getTelegramHouseholdChat('-100123')).toEqual(setup.household) + }) + + test('treats repeated unsetup as a no-op', async () => { + const { repository } = createRepositoryStub() + const service = createHouseholdSetupService(repository) + + const result = await service.unsetupGroupChat({ + actorIsAdmin: true, + telegramChatId: '-100123', + telegramChatType: 'supergroup' + }) + + expect(result).toEqual({ + status: 'noop' + }) + }) }) diff --git a/packages/application/src/household-setup-service.ts b/packages/application/src/household-setup-service.ts index 1e4c3cb..51be09c 100644 --- a/packages/application/src/household-setup-service.ts +++ b/packages/application/src/household-setup-service.ts @@ -41,6 +41,23 @@ export interface HouseholdSetupService { reason: 'not_admin' | 'household_not_found' | 'not_topic_message' } > + unsetupGroupChat(input: { + actorIsAdmin: boolean + telegramChatId: string + telegramChatType: string + }): Promise< + | { + status: 'reset' + household: HouseholdTelegramChatRecord + } + | { + status: 'noop' + } + | { + status: 'rejected' + reason: 'not_admin' | 'invalid_chat_type' + } + > } function isSupportedGroupChat(chatType: string): boolean { @@ -146,6 +163,36 @@ export function createHouseholdSetupService( household, binding } + }, + + async unsetupGroupChat(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + if (!isSupportedGroupChat(input.telegramChatType)) { + return { + status: 'rejected', + reason: 'invalid_chat_type' + } + } + + const household = await repository.getTelegramHouseholdChat(input.telegramChatId) + if (!household) { + return { + status: 'noop' + } + } + + await repository.clearHouseholdTopicBindings(household.householdId) + + return { + status: 'reset', + household + } } } } diff --git a/packages/application/src/locale-preference-service.test.ts b/packages/application/src/locale-preference-service.test.ts index 131a6e9..6ebdd64 100644 --- a/packages/application/src/locale-preference-service.test.ts +++ b/packages/application/src/locale-preference-service.test.ts @@ -38,6 +38,7 @@ function createRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: household.householdId, diff --git a/packages/application/src/miniapp-admin-service.test.ts b/packages/application/src/miniapp-admin-service.test.ts index 281c486..4d5b308 100644 --- a/packages/application/src/miniapp-admin-service.test.ts +++ b/packages/application/src/miniapp-admin-service.test.ts @@ -35,6 +35,7 @@ function repository(): HouseholdConfigurationRepository { topicName: 'Общие покупки' } ], + clearHouseholdTopicBindings: async () => {}, listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: 'household-1', diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index cc0e35b..5fe86d1 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -103,6 +103,7 @@ export interface HouseholdConfigurationRepository { telegramThreadId: string }): Promise listHouseholdTopicBindings(householdId: string): Promise + clearHouseholdTopicBindings(householdId: string): Promise listReminderTargets(): Promise upsertHouseholdJoinToken(input: { householdId: string diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 06c861f..1a77054 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -23,4 +23,8 @@ export interface TelegramPendingActionRepository { telegramUserId: string ): Promise clearPendingAction(telegramChatId: string, telegramUserId: string): Promise + clearPendingActionsForChat( + telegramChatId: string, + action?: TelegramPendingActionType + ): Promise }