feat(bot): add household admin approval flow

This commit is contained in:
2026-03-09 04:58:14 +04:00
parent 296035a221
commit fac2dc0e9d
14 changed files with 724 additions and 12 deletions

View File

@@ -14,6 +14,8 @@ export function createTelegramBot(token: string, logger?: Logger): Bot {
'/setup [household name] - Register this group as a household',
'/bind_purchase_topic - Bind the current topic as the purchase topic',
'/bind_feedback_topic - Bind the current topic as the feedback topic',
'/pending_members - List pending household join requests',
'/approve_member <telegram_user_id> - Approve a pending member',
'/anon <message> - Send anonymous household feedback in a private chat'
].join('\n')
)

View File

@@ -1,4 +1,8 @@
import type { HouseholdOnboardingService, HouseholdSetupService } from '@household/application'
import type {
HouseholdAdminService,
HouseholdOnboardingService,
HouseholdSetupService
} from '@household/application'
import type { Logger } from '@household/observability'
import type { Bot, Context } from 'grammy'
@@ -48,10 +52,32 @@ function bindRejectionMessage(
}
}
function adminRejectionMessage(
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
): string {
switch (reason) {
case 'not_admin':
return 'Only household admins can manage pending members.'
case 'household_not_found':
return 'Household is not configured for this chat yet. Run /setup first.'
case 'pending_not_found':
return 'Pending member not found. Use /pending_members to inspect the queue.'
}
}
function actorDisplayName(ctx: Context): string | undefined {
const firstName = ctx.from?.first_name?.trim()
const lastName = ctx.from?.last_name?.trim()
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
return fullName || ctx.from?.username?.trim() || undefined
}
export function registerHouseholdSetupCommands(options: {
bot: Bot
householdSetupService: HouseholdSetupService
householdOnboardingService: HouseholdOnboardingService
householdAdminService: HouseholdAdminService
logger?: Logger
}): void {
options.bot.command('start', async (ctx) => {
@@ -128,7 +154,17 @@ export function registerHouseholdSetupCommands(options: {
telegramChatId: ctx.chat.id.toString(),
telegramChatType: ctx.chat.type,
title: ctx.chat.title,
householdName: commandArgText(ctx)
householdName: commandArgText(ctx),
...(ctx.from?.id
? {
actorTelegramUserId: ctx.from.id.toString()
}
: {}),
...(actorDisplayName(ctx)
? {
actorDisplayName: actorDisplayName(ctx)!
}
: {})
})
if (result.status === 'rejected') {
@@ -172,14 +208,10 @@ export function registerHouseholdSetupCommands(options: {
reply_markup: {
inline_keyboard: [
[
...(joinDeepLink
? [
{
text: 'Join household',
url: joinDeepLink
}
]
: [])
{
text: 'Join household',
url: joinDeepLink
}
]
]
}
@@ -275,4 +307,77 @@ export function registerHouseholdSetupCommands(options: {
`Feedback topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
)
})
options.bot.command('pending_members', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /pending_members inside the household group.')
return
}
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId) {
await ctx.reply('Unable to identify sender for this command.')
return
}
const result = await options.householdAdminService.listPendingMembers({
actorTelegramUserId,
telegramChatId: ctx.chat.id.toString()
})
if (result.status === 'rejected') {
await ctx.reply(adminRejectionMessage(result.reason))
return
}
if (result.members.length === 0) {
await ctx.reply(`No pending members for ${result.householdName}.`)
return
}
await ctx.reply(
[
`Pending members for ${result.householdName}:`,
...result.members.map(
(member, index) =>
`${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`
),
'Approve with /approve_member <telegram_user_id>.'
].join('\n')
)
})
options.bot.command('approve_member', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /approve_member inside the household group.')
return
}
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId) {
await ctx.reply('Unable to identify sender for this command.')
return
}
const pendingTelegramUserId = commandArgText(ctx)
if (!pendingTelegramUserId) {
await ctx.reply('Usage: /approve_member <telegram_user_id>')
return
}
const result = await options.householdAdminService.approvePendingMember({
actorTelegramUserId,
telegramChatId: ctx.chat.id.toString(),
pendingTelegramUserId
})
if (result.status === 'rejected') {
await ctx.reply(adminRejectionMessage(result.reason))
return
}
await ctx.reply(
`Approved ${result.member.displayName} as an active member of ${result.householdName}.`
)
})
}

View File

@@ -2,6 +2,7 @@ import { webhookCallback } from 'grammy'
import {
createAnonymousFeedbackService,
createHouseholdAdminService,
createFinanceCommandService,
createHouseholdOnboardingService,
createHouseholdSetupService,
@@ -129,6 +130,9 @@ if (householdConfigurationRepositoryClient) {
householdSetupService: createHouseholdSetupService(
householdConfigurationRepositoryClient.repository
),
householdAdminService: createHouseholdAdminService(
householdConfigurationRepositoryClient.repository
),
householdOnboardingService: householdOnboardingService!,
logger: getLogger('household-setup')
})

View File

@@ -71,7 +71,29 @@ function onboardingRepository(): HouseholdConfigurationRepository {
return pending
},
getPendingHouseholdMember: async () => pending,
findPendingHouseholdMemberByTelegramUserId: async () => pending
findPendingHouseholdMemberByTelegramUserId: async () => pending,
ensureHouseholdMember: async (input) => ({
householdId: household.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
listPendingHouseholdMembers: async () => (pending ? [pending] : []),
approvePendingHouseholdMember: async (input) => {
if (!pending || pending.telegramUserId !== input.telegramUserId) {
return null
}
const member = {
householdId: household.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
isAdmin: input.isAdmin === true
}
pending = null
return member
}
}
}

View File

@@ -111,7 +111,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
languageCode: input.languageCode?.trim() || null
}),
getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null
findPendingHouseholdMemberByTelegramUserId: async () => null,
ensureHouseholdMember: async (input) => ({
householdId: household.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null
}
}