feat(bot): add safe group unsetup flow

This commit is contained in:
2026-03-11 06:08:34 +04:00
parent 1b8c6e87f6
commit b6b6f9e1b8
25 changed files with 495 additions and 0 deletions

View File

@@ -89,6 +89,19 @@ function createPromptRepository(): TelegramPendingActionRepository {
}, },
async clearPendingAction(telegramChatId, telegramUserId) { async clearPendingAction(telegramChatId, telegramUserId) {
store.delete(`${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, : null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({ upsertHouseholdJoinToken: async () => ({
householdId: 'household-1', householdId: 'household-1',

View File

@@ -74,6 +74,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => { upsertHouseholdJoinToken: async () => {
throw new Error('not implemented') throw new Error('not implemented')

View File

@@ -106,6 +106,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => { upsertHouseholdJoinToken: async () => {
throw new Error('not used') throw new Error('not used')
@@ -309,6 +310,17 @@ function createPromptRepository(): TelegramPendingActionRepository {
return pending return pending
}, },
async clearPendingAction() { async clearPendingAction() {
pending = null
},
async clearPendingActionsForChat(telegramChatId, action) {
if (!pending || pending.telegramChatId !== telegramChatId) {
return
}
if (action && pending.action !== action) {
return
}
pending = null pending = null
} }
} }

View File

@@ -55,6 +55,7 @@ function createRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => { upsertHouseholdJoinToken: async () => {
throw new Error('not implemented') throw new Error('not implemented')

View File

@@ -68,6 +68,11 @@ function createRejectedHouseholdSetupService(): HouseholdSetupService {
status: 'rejected', status: 'rejected',
reason: 'household_not_found' reason: 'household_not_found'
} }
},
async unsetupGroupChat() {
return {
status: 'noop'
}
} }
} }
} }
@@ -206,6 +211,19 @@ function createPromptRepository(): TelegramPendingActionRepository {
}, },
async clearPendingAction(telegramChatId, telegramUserId) { async clearPendingAction(telegramChatId, telegramUserId) {
store.delete(`${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) { async listHouseholdTopicBindings(householdId) {
return bindings.get(householdId) ?? [] return bindings.get(householdId) ?? []
}, },
async clearHouseholdTopicBindings(householdId) {
bindings.set(householdId, [])
},
async listReminderTargets() { async listReminderTargets() {
return [] return []
}, },
@@ -1060,4 +1081,232 @@ describe('registerHouseholdSetupCommands', () => {
telegramThreadId: '444' 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.'
}
})
})
}) })

View File

@@ -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( function bindRejectionMessage(
locale: BotLocale, locale: BotLocale,
reason: 'not_admin' | 'household_not_found' | 'not_topic_message' 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 } : {}) 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) => { options.bot.command('bind_purchase_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'purchase') await handleBindTopicCommand(ctx, 'purchase')
}) })

View File

@@ -8,6 +8,7 @@ export const enBotTranslations: BotTranslationCatalog = {
anon: 'Send anonymous household feedback', anon: 'Send anonymous household feedback',
cancel: 'Cancel the current prompt', cancel: 'Cancel the current prompt',
setup: 'Register this group as a household', setup: 'Register this group as a household',
unsetup: 'Reset topic setup for this group',
bind_purchase_topic: 'Bind the current topic as purchases', bind_purchase_topic: 'Bind the current topic as purchases',
bind_feedback_topic: 'Bind the current topic as feedback', bind_feedback_topic: 'Bind the current topic as feedback',
bind_reminders_topic: 'Bind the current topic as reminders', bind_reminders_topic: 'Bind the current topic as reminders',
@@ -94,6 +95,11 @@ export const enBotTranslations: BotTranslationCatalog = {
return 'Payments' 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.', useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
purchaseTopicSaved: (householdName, threadId) => purchaseTopicSaved: (householdName, threadId) =>
`Purchase topic saved for ${householdName} (thread ${threadId}).`, `Purchase topic saved for ${householdName} (thread ${threadId}).`,

View File

@@ -8,6 +8,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
anon: 'Отправить анонимное сообщение по дому', anon: 'Отправить анонимное сообщение по дому',
cancel: 'Отменить текущий ввод', cancel: 'Отменить текущий ввод',
setup: 'Подключить эту группу как дом', setup: 'Подключить эту группу как дом',
unsetup: 'Сбросить настройку топиков для этой группы',
bind_purchase_topic: 'Назначить текущий топик для покупок', bind_purchase_topic: 'Назначить текущий топик для покупок',
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
bind_reminders_topic: 'Назначить текущий топик для напоминаний', bind_reminders_topic: 'Назначить текущий топик для напоминаний',
@@ -96,6 +97,11 @@ export const ruBotTranslations: BotTranslationCatalog = {
return 'Оплаты' return 'Оплаты'
} }
}, },
onlyTelegramAdminsUnsetup: 'Только админы Telegram-группы могут запускать /unsetup.',
useUnsetupInGroup: 'Используйте /unsetup внутри группы дома.',
unsetupComplete: (householdName) =>
`Состояние настройки для ${householdName} сброшено. Запустите /setup ещё раз, чтобы заново привязать топики.`,
unsetupNoop: 'Для этой группы пока нечего сбрасывать. Когда будете готовы, запустите /setup.',
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.', useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
purchaseTopicSaved: (householdName, threadId) => purchaseTopicSaved: (householdName, threadId) =>
`Топик покупок сохранён для ${householdName} (тред ${threadId}).`, `Топик покупок сохранён для ${householdName} (тред ${threadId}).`,

View File

@@ -6,6 +6,7 @@ export type TelegramCommandName =
| 'anon' | 'anon'
| 'cancel' | 'cancel'
| 'setup' | 'setup'
| 'unsetup'
| 'bind_purchase_topic' | 'bind_purchase_topic'
| 'bind_feedback_topic' | 'bind_feedback_topic'
| 'bind_reminders_topic' | 'bind_reminders_topic'
@@ -20,6 +21,7 @@ export interface BotCommandDescriptions {
anon: string anon: string
cancel: string cancel: string
setup: string setup: string
unsetup: string
bind_purchase_topic: string bind_purchase_topic: string
bind_feedback_topic: string bind_feedback_topic: string
bind_reminders_topic: string bind_reminders_topic: string
@@ -86,6 +88,10 @@ export interface BotTranslationCatalog {
setupTopicBindNotAvailable: string setupTopicBindNotAvailable: string
setupTopicBindRoleName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string setupTopicBindRoleName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string
setupTopicSuggestedName: (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 useBindPurchaseTopicInGroup: string
purchaseTopicSaved: (householdName: string, threadId: string) => string purchaseTopicSaved: (householdName: string, threadId: string) => string
useBindFeedbackTopicInGroup: string useBindFeedbackTopicInGroup: string

View File

@@ -49,6 +49,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
topicName: 'Общие покупки' topicName: 'Общие покупки'
} }
], ],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async (input) => ({ upsertHouseholdJoinToken: async (input) => ({
householdId: household.householdId, householdId: household.householdId,

View File

@@ -59,6 +59,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async (input) => ({ upsertHouseholdJoinToken: async (input) => ({
householdId: household.householdId, householdId: household.householdId,

View File

@@ -45,6 +45,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async (input) => ({ upsertHouseholdJoinToken: async (input) => ({
householdId: household.householdId, householdId: household.householdId,

View File

@@ -152,6 +152,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async (input) => ({ upsertHouseholdJoinToken: async (input) => ({
householdId: household.householdId, householdId: household.householdId,

View File

@@ -66,6 +66,7 @@ function repository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({ upsertHouseholdJoinToken: async () => ({
householdId: household.householdId, householdId: household.householdId,

View File

@@ -34,6 +34,7 @@ const GROUP_MEMBER_COMMAND_NAMES = [
const GROUP_ADMIN_COMMAND_NAMES = [ const GROUP_ADMIN_COMMAND_NAMES = [
...GROUP_MEMBER_COMMAND_NAMES, ...GROUP_MEMBER_COMMAND_NAMES,
'setup', 'setup',
'unsetup',
'bind_purchase_topic', 'bind_purchase_topic',
'bind_feedback_topic', 'bind_feedback_topic',
'bind_reminders_topic', 'bind_reminders_topic',

View File

@@ -503,6 +503,12 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return rows.map(toHouseholdTopicBindingRecord) return rows.map(toHouseholdTopicBindingRecord)
}, },
async clearHouseholdTopicBindings(householdId) {
await db
.delete(schema.householdTopicBindings)
.where(eq(schema.householdTopicBindings.householdId, householdId))
},
async listReminderTargets() { async listReminderTargets() {
const rows = await db const rows = await db
.select({ .select({

View File

@@ -151,6 +151,19 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
eq(schema.telegramPendingActions.telegramUserId, telegramUserId) 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)
)
} }
} }

View File

@@ -60,6 +60,7 @@ function createRepositoryStub() {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async (input) => upsertHouseholdJoinToken: async (input) =>
({ ({

View File

@@ -55,6 +55,7 @@ function createRepositoryStub() {
async listHouseholdTopicBindings() { async listHouseholdTopicBindings() {
return [] return []
}, },
async clearHouseholdTopicBindings() {},
async listReminderTargets() { async listReminderTargets() {
return [] return []
}, },

View File

@@ -93,6 +93,9 @@ function createRepositoryStub() {
async listHouseholdTopicBindings(householdId) { async listHouseholdTopicBindings(householdId) {
return bindings.get(householdId) ?? [] return bindings.get(householdId) ?? []
}, },
async clearHouseholdTopicBindings(householdId) {
bindings.set(householdId, [])
},
async listReminderTargets() { async listReminderTargets() {
return [] return []
}, },
@@ -514,4 +517,56 @@ describe('createHouseholdSetupService', () => {
reason: 'not_topic_message' 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'
})
})
}) })

View File

@@ -41,6 +41,23 @@ export interface HouseholdSetupService {
reason: 'not_admin' | 'household_not_found' | 'not_topic_message' 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 { function isSupportedGroupChat(chatType: string): boolean {
@@ -146,6 +163,36 @@ export function createHouseholdSetupService(
household, household,
binding 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
}
} }
} }
} }

View File

@@ -38,6 +38,7 @@ function createRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null, getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null, findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [], listHouseholdTopicBindings: async () => [],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({ upsertHouseholdJoinToken: async () => ({
householdId: household.householdId, householdId: household.householdId,

View File

@@ -35,6 +35,7 @@ function repository(): HouseholdConfigurationRepository {
topicName: 'Общие покупки' topicName: 'Общие покупки'
} }
], ],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [], listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({ upsertHouseholdJoinToken: async () => ({
householdId: 'household-1', householdId: 'household-1',

View File

@@ -103,6 +103,7 @@ export interface HouseholdConfigurationRepository {
telegramThreadId: string telegramThreadId: string
}): Promise<HouseholdTopicBindingRecord | null> }): Promise<HouseholdTopicBindingRecord | null>
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]> listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
clearHouseholdTopicBindings(householdId: string): Promise<void>
listReminderTargets(): Promise<readonly ReminderTarget[]> listReminderTargets(): Promise<readonly ReminderTarget[]>
upsertHouseholdJoinToken(input: { upsertHouseholdJoinToken(input: {
householdId: string householdId: string

View File

@@ -23,4 +23,8 @@ export interface TelegramPendingActionRepository {
telegramUserId: string telegramUserId: string
): Promise<TelegramPendingActionRecord | null> ): Promise<TelegramPendingActionRecord | null>
clearPendingAction(telegramChatId: string, telegramUserId: string): Promise<void> clearPendingAction(telegramChatId: string, telegramUserId: string): Promise<void>
clearPendingActionsForChat(
telegramChatId: string,
action?: TelegramPendingActionType
): Promise<void>
} }