Files
household-bot/apps/bot/src/household-setup.ts

384 lines
11 KiB
TypeScript

import type {
HouseholdAdminService,
HouseholdOnboardingService,
HouseholdSetupService
} from '@household/application'
import type { Logger } from '@household/observability'
import type { Bot, Context } from 'grammy'
function commandArgText(ctx: Context): string {
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
}
function isGroupChat(ctx: Context): ctx is Context & {
chat: NonNullable<Context['chat']> & { type: 'group' | 'supergroup'; title?: string }
} {
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
}
function isTopicMessage(ctx: Context): boolean {
const message = ctx.msg
return !!message && 'is_topic_message' in message && message.is_topic_message === true
}
async function isGroupAdmin(ctx: Context): Promise<boolean> {
if (!ctx.chat || !ctx.from) {
return false
}
const member = await ctx.api.getChatMember(ctx.chat.id, ctx.from.id)
return member.status === 'creator' || member.status === 'administrator'
}
function setupRejectionMessage(reason: 'not_admin' | 'invalid_chat_type'): string {
switch (reason) {
case 'not_admin':
return 'Only Telegram group admins can run /setup.'
case 'invalid_chat_type':
return 'Use /setup inside a group or supergroup.'
}
}
function bindRejectionMessage(
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
): string {
switch (reason) {
case 'not_admin':
return 'Only Telegram group admins can bind household topics.'
case 'household_not_found':
return 'Household is not configured for this chat yet. Run /setup first.'
case 'not_topic_message':
return 'Run this command inside the target topic thread.'
}
}
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) => {
if (ctx.chat?.type !== 'private') {
return
}
if (!ctx.from) {
await ctx.reply('Telegram user identity is required to join a household.')
return
}
const startPayload = commandArgText(ctx)
if (!startPayload.startsWith('join_')) {
await ctx.reply('Send /help to see available commands.')
return
}
const joinToken = startPayload.slice('join_'.length).trim()
if (!joinToken) {
await ctx.reply('Invalid household invite link.')
return
}
const identity = {
telegramUserId: ctx.from.id.toString(),
displayName:
[ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ').trim() ||
ctx.from.username ||
`Telegram ${ctx.from.id}`,
...(ctx.from.username
? {
username: ctx.from.username
}
: {}),
...(ctx.from.language_code
? {
languageCode: ctx.from.language_code
}
: {})
}
const result = await options.householdOnboardingService.joinHousehold({
identity,
joinToken
})
if (result.status === 'invalid_token') {
await ctx.reply('This household invite link is invalid or expired.')
return
}
if (result.status === 'active') {
await ctx.reply(
`You are already an active member. Open the mini app to view ${result.member.displayName}.`
)
return
}
await ctx.reply(
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`
)
})
options.bot.command('setup', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /setup inside the household group.')
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const result = await options.householdSetupService.setupGroupChat({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
telegramChatType: ctx.chat.type,
title: ctx.chat.title,
householdName: commandArgText(ctx),
...(ctx.from?.id
? {
actorTelegramUserId: ctx.from.id.toString()
}
: {}),
...(actorDisplayName(ctx)
? {
actorDisplayName: actorDisplayName(ctx)!
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(setupRejectionMessage(result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.chat_registered',
telegramChatId: result.household.telegramChatId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString(),
status: result.status
},
'Household group registered'
)
const action = result.status === 'created' ? 'created' : 'already registered'
const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({
householdId: result.household.householdId,
...(ctx.from?.id
? {
actorTelegramUserId: ctx.from.id.toString()
}
: {})
})
const joinDeepLink = ctx.me.username
? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
: null
await ctx.reply(
[
`Household ${action}: ${result.household.householdName}`,
`Chat ID: ${result.household.telegramChatId}`,
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.',
'Members should open the bot chat from the button below and confirm the join request there.'
].join('\n'),
joinDeepLink
? {
reply_markup: {
inline_keyboard: [
[
{
text: 'Join household',
url: joinDeepLink
}
]
]
}
}
: {}
)
})
options.bot.command('bind_purchase_topic', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /bind_purchase_topic inside the household group topic.')
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const telegramThreadId =
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
? ctx.msg.message_thread_id?.toString()
: undefined
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
role: 'purchase',
...(telegramThreadId
? {
telegramThreadId
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.topic_bound',
role: result.binding.role,
telegramChatId: result.household.telegramChatId,
telegramThreadId: result.binding.telegramThreadId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString()
},
'Household topic bound'
)
await ctx.reply(
`Purchase topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
)
})
options.bot.command('bind_feedback_topic', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /bind_feedback_topic inside the household group topic.')
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const telegramThreadId =
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
? ctx.msg.message_thread_id?.toString()
: undefined
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
role: 'feedback',
...(telegramThreadId
? {
telegramThreadId
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.topic_bound',
role: result.binding.role,
telegramChatId: result.household.telegramChatId,
telegramThreadId: result.binding.telegramThreadId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString()
},
'Household topic bound'
)
await ctx.reply(
`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}.`
)
})
}