mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(bot): add safe group unsetup flow
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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}).`,
|
||||
|
||||
@@ -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}).`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,6 +49,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
topicName: 'Общие покупки'
|
||||
}
|
||||
],
|
||||
clearHouseholdTopicBindings: async () => {},
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -66,6 +66,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
clearHouseholdTopicBindings: async () => {},
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async () => ({
|
||||
householdId: household.householdId,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ function createRepositoryStub() {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
clearHouseholdTopicBindings: async () => {},
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) =>
|
||||
({
|
||||
|
||||
@@ -55,6 +55,7 @@ function createRepositoryStub() {
|
||||
async listHouseholdTopicBindings() {
|
||||
return []
|
||||
},
|
||||
async clearHouseholdTopicBindings() {},
|
||||
async listReminderTargets() {
|
||||
return []
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
clearHouseholdTopicBindings: async () => {},
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async () => ({
|
||||
householdId: household.householdId,
|
||||
|
||||
@@ -35,6 +35,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
topicName: 'Общие покупки'
|
||||
}
|
||||
],
|
||||
clearHouseholdTopicBindings: async () => {},
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async () => ({
|
||||
householdId: 'household-1',
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface HouseholdConfigurationRepository {
|
||||
telegramThreadId: string
|
||||
}): Promise<HouseholdTopicBindingRecord | null>
|
||||
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
||||
clearHouseholdTopicBindings(householdId: string): Promise<void>
|
||||
listReminderTargets(): Promise<readonly ReminderTarget[]>
|
||||
upsertHouseholdJoinToken(input: {
|
||||
householdId: string
|
||||
|
||||
@@ -23,4 +23,8 @@ export interface TelegramPendingActionRepository {
|
||||
telegramUserId: string
|
||||
): Promise<TelegramPendingActionRecord | null>
|
||||
clearPendingAction(telegramChatId: string, telegramUserId: string): Promise<void>
|
||||
clearPendingActionsForChat(
|
||||
telegramChatId: string,
|
||||
action?: TelegramPendingActionType
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user