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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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