feat(bot): restore /bind command and improve /setup welcoming experience

This commit is contained in:
2026-03-15 01:17:22 +04:00
parent b2e1e0f213
commit f4e5a49621
6 changed files with 137 additions and 345 deletions

View File

@@ -20,7 +20,7 @@ import { resolveReplyLocale } from './bot-locale'
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
const SETUP_BIND_TOPIC_ACTION = 'setup_topic_binding' as const
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
'chat',
'purchase',
@@ -39,11 +39,6 @@ function isGroupChat(ctx: Context): ctx is Context & {
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
@@ -79,63 +74,6 @@ function unsetupRejectionMessage(
}
}
function bindRejectionMessage(
locale: BotLocale,
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
): string {
const t = getBotTranslations(locale).setup
switch (reason) {
case 'not_admin':
return t.onlyTelegramAdminsBindTopics
case 'household_not_found':
return t.householdNotConfigured
case 'not_topic_message':
return t.useCommandInTopic
}
}
function bindTopicUsageMessage(
locale: BotLocale,
role: 'chat' | 'purchase' | 'feedback' | 'reminders' | 'payments'
): string {
const t = getBotTranslations(locale).setup
switch (role) {
case 'chat':
return t.useBindChatTopicInGroup
case 'purchase':
return t.useBindPurchaseTopicInGroup
case 'feedback':
return t.useBindFeedbackTopicInGroup
case 'reminders':
return t.useBindRemindersTopicInGroup
case 'payments':
return t.useBindPaymentsTopicInGroup
}
}
function bindTopicSuccessMessage(
locale: BotLocale,
role: 'chat' | 'purchase' | 'feedback' | 'reminders' | 'payments',
householdName: string,
threadId: string
): string {
const t = getBotTranslations(locale).setup
switch (role) {
case 'chat':
return t.chatTopicSaved(householdName, threadId)
case 'purchase':
return t.purchaseTopicSaved(householdName, threadId)
case 'feedback':
return t.feedbackTopicSaved(householdName, threadId)
case 'reminders':
return t.remindersTopicSaved(householdName, threadId)
case 'payments':
return t.paymentsTopicSaved(householdName, threadId)
}
}
function adminRejectionMessage(
locale: BotLocale,
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
@@ -473,63 +411,6 @@ export function registerHouseholdSetupCommands(options: {
})
}
async function handleBindTopicCommand(
ctx: Context,
role: 'chat' | 'purchase' | 'feedback' | 'reminders' | 'payments'
): Promise<void> {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
if (!isGroupChat(ctx)) {
await ctx.reply(bindTopicUsageMessage(locale, role))
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,
...(telegramThreadId
? {
telegramThreadId
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(locale, 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(
bindTopicSuccessMessage(
locale,
role,
result.household.householdName,
result.binding.telegramThreadId
)
)
}
options.bot.command('start', async (ctx) => {
const fallbackLocale = await resolveReplyLocale({
ctx,
@@ -714,7 +595,7 @@ export function registerHouseholdSetupCommands(options: {
if (result.status === 'noop') {
await options.promptRepository?.clearPendingActionsForChat(
telegramChatId,
SETUP_BIND_TOPIC_ACTION
'setup_topic_binding'
)
await ctx.reply(t.setup.unsetupNoop)
return
@@ -722,7 +603,7 @@ export function registerHouseholdSetupCommands(options: {
await options.promptRepository?.clearPendingActionsForChat(
telegramChatId,
SETUP_BIND_TOPIC_ACTION
'setup_topic_binding'
)
options.logger?.info(
@@ -738,103 +619,6 @@ export function registerHouseholdSetupCommands(options: {
await ctx.reply(t.setup.unsetupComplete(result.household.householdName))
})
options.bot.command('bind_chat_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'chat')
})
options.bot.command('bind_purchase_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'purchase')
})
options.bot.command('bind_feedback_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'feedback')
})
options.bot.command('bind_reminders_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'reminders')
})
options.bot.command('bind_payments_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'payments')
})
options.bot.command('bind', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) {
await ctx.reply(t.setup.useBindInTopic)
return
}
if (!options.householdConfigurationRepository) {
await ctx.reply(t.setup.householdNotConfigured)
return
}
const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(
ctx.chat.id.toString()
)
if (!household) {
await ctx.reply(t.setup.householdNotConfigured)
return
}
if (!(await isGroupAdmin(ctx))) {
await ctx.reply(t.setup.onlyTelegramAdminsBindTopics)
return
}
const telegramThreadId =
ctx.msg && 'message_thread_id' in ctx.msg ? ctx.msg.message_thread_id?.toString() : null
// If not in a topic, show error
if (!telegramThreadId) {
await ctx.reply(t.setup.useBindInTopic)
return
}
// Check if this topic is already bound
const existingBinding =
await options.householdConfigurationRepository.findHouseholdTopicByTelegramContext({
telegramChatId: ctx.chat.id.toString(),
telegramThreadId
})
if (existingBinding) {
const roleLabel = setupTopicRoleLabel(locale, existingBinding.role)
await ctx.reply(t.setup.topicAlreadyBound(roleLabel))
return
}
// Get all existing bindings to show which roles are available
const bindings = await options.householdConfigurationRepository.listHouseholdTopicBindings(
household.householdId
)
const boundRoles = new Set(bindings.map((b) => b.role))
const availableRoles = HOUSEHOLD_TOPIC_ROLE_ORDER.filter((role) => !boundRoles.has(role))
if (availableRoles.length === 0) {
await ctx.reply(t.setup.allRolesConfigured)
return
}
// Show role selection buttons
await ctx.reply(t.setup.bindSelectRole, {
reply_markup: {
inline_keyboard: availableRoles.map((role) => [
{
text: setupTopicRoleLabel(locale, role),
callback_data: `bind_topic:${role}:${telegramThreadId}`
}
])
}
})
})
options.bot.command('pending_members', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
@@ -974,6 +758,67 @@ export function registerHouseholdSetupCommands(options: {
})
})
options.bot.command('bind', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) {
await ctx.reply(t.setup.useSetupInGroup)
return
}
if (!ctx.message?.message_thread_id) {
await ctx.reply(t.setup.useCommandInTopic)
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
if (!actorIsAdmin) {
await ctx.reply(t.setup.onlyTelegramAdminsBindTopics)
return
}
const telegramChatId = ctx.chat.id.toString()
const household = options.householdConfigurationRepository
? await options.householdConfigurationRepository.getTelegramHouseholdChat(telegramChatId)
: null
if (!household) {
await ctx.reply(t.setup.householdNotConfigured)
return
}
const bindings = options.householdConfigurationRepository
? await options.householdConfigurationRepository.listHouseholdTopicBindings(
household.householdId
)
: []
const configuredRoles = new Set(bindings.map((b) => b.role))
const availableRoles = HOUSEHOLD_TOPIC_ROLE_ORDER.filter((role) => !configuredRoles.has(role))
if (availableRoles.length === 0) {
await ctx.reply(t.setup.allRolesConfigured)
return
}
const rows = availableRoles.map((role) => [
{
text: setupTopicRoleLabel(locale, role),
callback_data: `bind_topic:${role}:${ctx.message!.message_thread_id}`
}
])
await ctx.reply(t.setup.bindSelectRole, {
reply_markup: {
inline_keyboard: rows
}
})
})
options.bot.callbackQuery(
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
async (ctx) => {
@@ -1095,7 +940,10 @@ export function registerHouseholdSetupCommands(options: {
if (result.status === 'rejected') {
await ctx.answerCallbackQuery({
text: bindRejectionMessage(locale, result.reason),
text:
result.reason === 'not_admin'
? t.onlyTelegramAdminsBindTopics
: t.householdNotConfigured,
show_alert: true
})
return
@@ -1133,9 +981,8 @@ export function registerHouseholdSetupCommands(options: {
}
)
// Bind topic callback from /bind command
options.bot.callbackQuery(
new RegExp(`^bind_topic:(chat|purchase|feedback|reminders|payments):(\\d+)$`),
/^bind_topic:(chat|purchase|feedback|reminders|payments):(\d+)$/,
async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
@@ -1151,7 +998,11 @@ export function registerHouseholdSetupCommands(options: {
return
}
const role = ctx.match[1] as HouseholdTopicRole
const telegramThreadId = ctx.match[2]!
const telegramChatId = ctx.chat.id.toString()
const actorIsAdmin = await isGroupAdmin(ctx)
if (!actorIsAdmin) {
await ctx.answerCallbackQuery({
text: t.onlyTelegramAdminsBindTopics,
@@ -1160,19 +1011,19 @@ export function registerHouseholdSetupCommands(options: {
return
}
const role = ctx.match[1] as HouseholdTopicRole
const telegramThreadId = ctx.match[2]!
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
telegramChatId,
telegramThreadId,
role
})
if (result.status === 'rejected') {
await ctx.answerCallbackQuery({
text: bindRejectionMessage(locale, result.reason),
text:
result.reason === 'not_admin'
? t.onlyTelegramAdminsBindTopics
: t.householdNotConfigured,
show_alert: true
})
return
@@ -1185,10 +1036,11 @@ export function registerHouseholdSetupCommands(options: {
)
})
// Edit the role selection message to show success
await ctx.editMessageText(
t.topicBoundSuccess(setupTopicRoleLabel(locale, role), result.household.householdName)
)
if (ctx.msg) {
await ctx.editMessageText(
t.topicBoundSuccess(setupTopicRoleLabel(locale, role), result.household.householdName)
)
}
}
)
}