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) {
|
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',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}).`,
|
||||||
|
|||||||
@@ -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}).`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function createRepositoryStub() {
|
|||||||
async listHouseholdTopicBindings() {
|
async listHouseholdTopicBindings() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
|
async clearHouseholdTopicBindings() {},
|
||||||
async listReminderTargets() {
|
async listReminderTargets() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user