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) {
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',

View File

@@ -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')

View File

@@ -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
}
}

View File

@@ -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')

View File

@@ -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.'
}
})
})
})

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(
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')
})

View File

@@ -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}).`,

View File

@@ -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}).`,

View File

@@ -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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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',