feat(bot): add guided setup topic binding flow

This commit is contained in:
2026-03-11 05:20:53 +04:00
parent 60a5cd332e
commit 1b8c6e87f6
8 changed files with 1296 additions and 40 deletions

View File

@@ -5,6 +5,18 @@ import type {
HouseholdOnboardingService,
HouseholdSetupService
} from '@household/application'
import { createHouseholdSetupService } from '@household/application'
import { nowInstant, Temporal } from '@household/domain'
import type {
HouseholdConfigurationRepository,
HouseholdJoinTokenRecord,
HouseholdMemberRecord,
HouseholdPendingMemberRecord,
HouseholdTelegramChatRecord,
HouseholdTopicBindingRecord,
TelegramPendingActionRecord,
TelegramPendingActionRepository
} from '@household/ports'
import { createTelegramBot } from './bot'
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
@@ -43,7 +55,7 @@ function startUpdate(text: string, languageCode?: string) {
}
}
function createHouseholdSetupService(): HouseholdSetupService {
function createRejectedHouseholdSetupService(): HouseholdSetupService {
return {
async setupGroupChat() {
return {
@@ -77,6 +89,392 @@ function createHouseholdAdminService(): HouseholdAdminService {
}
}
function groupCommandUpdate(text: string) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 3001,
message: {
message_id: 81,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup',
title: 'Kojori House'
},
from: {
id: 123456,
is_bot: false,
first_name: 'Stan',
language_code: 'en'
},
text,
entities: [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
}
}
}
function groupCallbackUpdate(data: string) {
return {
update_id: 3002,
callback_query: {
id: 'callback-1',
from: {
id: 123456,
is_bot: false,
first_name: 'Stan',
language_code: 'en'
},
chat_instance: 'group-instance-1',
data,
message: {
message_id: 91,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup',
title: 'Kojori House'
},
text: 'placeholder'
}
}
}
}
function topicMessageUpdate(text: string, threadId: number) {
return {
update_id: 3003,
message: {
message_id: 92,
date: Math.floor(Date.now() / 1000),
is_topic_message: true,
message_thread_id: threadId,
chat: {
id: -100123456,
type: 'supergroup',
title: 'Kojori House'
},
from: {
id: 123456,
is_bot: false,
first_name: 'Stan',
language_code: 'en'
},
text
}
}
}
function createPromptRepository(): TelegramPendingActionRepository {
const store = new Map<string, TelegramPendingActionRecord>()
return {
async upsertPendingAction(input) {
const record = {
...input,
payload: {
...input.payload
}
}
store.set(`${input.telegramChatId}:${input.telegramUserId}`, record)
return record
},
async getPendingAction(telegramChatId, telegramUserId) {
const key = `${telegramChatId}:${telegramUserId}`
const record = store.get(key)
if (!record) {
return null
}
if (record.expiresAt && Temporal.Instant.compare(record.expiresAt, nowInstant()) <= 0) {
store.delete(key)
return null
}
return {
...record,
payload: {
...record.payload
}
}
},
async clearPendingAction(telegramChatId, telegramUserId) {
store.delete(`${telegramChatId}:${telegramUserId}`)
}
}
}
function createHouseholdConfigurationRepository(): HouseholdConfigurationRepository {
const households = new Map<string, HouseholdTelegramChatRecord>()
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
const joinTokens = new Map<string, HouseholdJoinTokenRecord>()
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
const members = new Map<string, HouseholdMemberRecord>()
return {
async registerTelegramHouseholdChat(input) {
const existing = households.get(input.telegramChatId)
if (existing) {
const next = {
...existing,
telegramChatType: input.telegramChatType,
title: input.title?.trim() || existing.title
}
households.set(input.telegramChatId, next)
return {
status: 'existing',
household: next
}
}
const created: HouseholdTelegramChatRecord = {
householdId: 'household-1',
householdName: input.householdName,
telegramChatId: input.telegramChatId,
telegramChatType: input.telegramChatType,
title: input.title?.trim() || null,
defaultLocale: 'en'
}
households.set(input.telegramChatId, created)
return {
status: 'created',
household: created
}
},
async getTelegramHouseholdChat(telegramChatId) {
return households.get(telegramChatId) ?? null
},
async getHouseholdChatByHouseholdId(householdId) {
return [...households.values()].find((entry) => entry.householdId === householdId) ?? null
},
async bindHouseholdTopic(input) {
const next: HouseholdTopicBindingRecord = {
householdId: input.householdId,
role: input.role,
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null
}
const existing = bindings.get(input.householdId) ?? []
bindings.set(
input.householdId,
[...existing.filter((entry) => entry.role !== input.role), next].sort((left, right) =>
left.role.localeCompare(right.role)
)
)
return next
},
async getHouseholdTopicBinding(householdId, role) {
return bindings.get(householdId)?.find((entry) => entry.role === role) ?? null
},
async findHouseholdTopicByTelegramContext(input) {
const household = households.get(input.telegramChatId)
if (!household) {
return null
}
return (
bindings
.get(household.householdId)
?.find((entry) => entry.telegramThreadId === input.telegramThreadId) ?? null
)
},
async listHouseholdTopicBindings(householdId) {
return bindings.get(householdId) ?? []
},
async listReminderTargets() {
return []
},
async upsertHouseholdJoinToken(input) {
const household = [...households.values()].find(
(entry) => entry.householdId === input.householdId
)
if (!household) {
throw new Error('Missing household')
}
const record: HouseholdJoinTokenRecord = {
householdId: household.householdId,
householdName: household.householdName,
token: input.token,
createdByTelegramUserId: input.createdByTelegramUserId ?? null
}
joinTokens.set(household.householdId, record)
return record
},
async getHouseholdJoinToken(householdId) {
return joinTokens.get(householdId) ?? null
},
async getHouseholdByJoinToken(token) {
const record = [...joinTokens.values()].find((entry) => entry.token === token)
if (!record) {
return null
}
return (
[...households.values()].find((entry) => entry.householdId === record.householdId) ?? null
)
},
async upsertPendingHouseholdMember(input) {
const household = [...households.values()].find(
(entry) => entry.householdId === input.householdId
)
if (!household) {
throw new Error('Missing household')
}
const record: HouseholdPendingMemberRecord = {
householdId: household.householdId,
householdName: household.householdName,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null,
householdDefaultLocale: household.defaultLocale
}
pendingMembers.set(`${input.householdId}:${input.telegramUserId}`, record)
return record
},
async getPendingHouseholdMember(householdId, telegramUserId) {
return pendingMembers.get(`${householdId}:${telegramUserId}`) ?? null
},
async findPendingHouseholdMemberByTelegramUserId(telegramUserId) {
return (
[...pendingMembers.values()].find((entry) => entry.telegramUserId === telegramUserId) ??
null
)
},
async ensureHouseholdMember(input) {
const key = `${input.householdId}:${input.telegramUserId}`
const existing = members.get(key)
const household =
[...households.values()].find((entry) => entry.householdId === input.householdId) ?? null
if (!household) {
throw new Error('Missing household')
}
const next: HouseholdMemberRecord = {
id: existing?.id ?? `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
preferredLocale: input.preferredLocale ?? existing?.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: input.rentShareWeight ?? existing?.rentShareWeight ?? 1,
isAdmin: input.isAdmin === true || existing?.isAdmin === true
}
members.set(key, next)
return next
},
async getHouseholdMember(householdId, telegramUserId) {
return members.get(`${householdId}:${telegramUserId}`) ?? null
},
async listHouseholdMembers(householdId) {
return [...members.values()].filter((entry) => entry.householdId === householdId)
},
async getHouseholdBillingSettings(householdId) {
return {
householdId,
settlementCurrency: 'GEL',
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}
},
async updateHouseholdBillingSettings(input) {
return {
householdId: input.householdId,
settlementCurrency: input.settlementCurrency ?? 'GEL',
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
}
},
async listHouseholdUtilityCategories() {
return []
},
async upsertHouseholdUtilityCategory(input) {
return {
id: input.slug ?? 'utility-1',
householdId: input.householdId,
slug: input.slug ?? 'utility',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}
},
async listHouseholdMembersByTelegramUserId(telegramUserId) {
return [...members.values()].filter((entry) => entry.telegramUserId === telegramUserId)
},
async listPendingHouseholdMembers(householdId) {
return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId)
},
async approvePendingHouseholdMember(input) {
const key = `${input.householdId}:${input.telegramUserId}`
const pending = pendingMembers.get(key)
if (!pending) {
return null
}
pendingMembers.delete(key)
const member: HouseholdMemberRecord = {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
preferredLocale: null,
householdDefaultLocale: pending.householdDefaultLocale,
rentShareWeight: 1,
isAdmin: input.isAdmin === true
}
members.set(key, member)
return member
},
async updateHouseholdDefaultLocale(householdId, locale) {
const household = [...households.values()].find((entry) => entry.householdId === householdId)
if (!household) {
throw new Error('Missing household')
}
const next = {
...household,
defaultLocale: locale
}
households.set(next.telegramChatId, next)
return next
},
async updateMemberPreferredLocale(householdId, telegramUserId, locale) {
const key = `${householdId}:${telegramUserId}`
const member = members.get(key)
return member
? {
...member,
preferredLocale: locale
}
: null
},
async promoteHouseholdAdmin() {
return null
},
async updateHouseholdMemberRentShareWeight() {
return null
}
}
}
describe('buildJoinMiniAppUrl', () => {
test('adds join token and bot username query parameters', () => {
const url = buildJoinMiniAppUrl(
@@ -156,7 +554,7 @@ describe('registerHouseholdSetupCommands', () => {
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(),
householdSetupService: createRejectedHouseholdSetupService(),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
miniAppUrl: 'https://miniapp.example.app'
@@ -246,7 +644,7 @@ describe('registerHouseholdSetupCommands', () => {
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(),
householdSetupService: createRejectedHouseholdSetupService(),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
miniAppUrl: 'https://miniapp.example.app'
@@ -271,4 +669,395 @@ describe('registerHouseholdSetupCommands', () => {
}
})
})
test('shows setup checklist with create and bind buttons for missing topics', 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
})
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'
}
}
}
}
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(repository),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
householdConfigurationRepository: repository,
promptRepository: createPromptRepository()
})
await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never)
expect(calls).toHaveLength(2)
const sendPayload = calls[1]?.payload as {
chat_id?: number
text?: string
reply_markup?: unknown
}
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: -100123456
}
})
expect(sendPayload.text).toContain('Household created: Kojori House')
expect(sendPayload.text).toContain('- purchases: not configured')
expect(sendPayload.text).toContain('- payments: not configured')
expect(sendPayload.reply_markup).toMatchObject({
inline_keyboard: expect.arrayContaining([
[
{
text: 'Join household',
url: 'https://t.me/household_test_bot?start=join_join-token'
}
],
[
{
text: 'Create purchases',
callback_data: 'setup_topic:create:purchase'
},
{
text: 'Bind purchases',
callback_data: 'setup_topic:bind:purchase'
}
]
])
})
})
test('creates and binds a missing setup topic from callback', 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 === 'createForumTopic') {
return {
ok: true,
result: {
name: 'Purchases',
icon_color: 7322096,
message_thread_id: 77
}
} as never
}
return {
ok: true,
result:
method === 'editMessageText'
? {
message_id: 91,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
: true
} as never
})
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(repository),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
householdConfigurationRepository: repository,
promptRepository
})
await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never)
calls.length = 0
await bot.handleUpdate(groupCallbackUpdate('setup_topic:create:purchase') as never)
expect(calls[1]).toMatchObject({
method: 'createForumTopic',
payload: {
chat_id: -100123456,
name: 'Shared purchases'
}
})
expect(calls[2]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'purchases topic created and bound: Shared purchases.'
}
})
expect(calls[3]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: -100123456,
message_id: 91,
text: expect.stringContaining('- purchases: bound to Shared purchases')
}
})
expect(await repository.getHouseholdTopicBinding('household-1', 'purchase')).toMatchObject({
telegramThreadId: '77',
topicName: 'Shared purchases'
})
})
test('arms manual setup topic binding and consumes the next topic message', 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
})
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(repository),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
householdConfigurationRepository: repository,
promptRepository
})
await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never)
calls.length = 0
await bot.handleUpdate(groupCallbackUpdate('setup_topic:bind:payments') as never)
expect(calls[1]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'Binding mode is on for payments. Open the target topic and send any message there within 10 minutes.'
}
})
expect(await promptRepository.getPendingAction('-100123456', '123456')).toMatchObject({
action: 'setup_topic_binding',
payload: {
role: 'payments'
}
})
calls.length = 0
await bot.handleUpdate(topicMessageUpdate('hello from payments', 444) as never)
expect(calls).toHaveLength(2)
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: -100123456,
message_thread_id: 444,
text: 'Payments topic saved for Kojori House (thread 444).'
}
})
expect(await promptRepository.getPendingAction('-100123456', '123456')).toBeNull()
expect(await repository.getHouseholdTopicBinding('household-1', 'payments')).toMatchObject({
telegramThreadId: '444'
})
})
})

View File

@@ -4,14 +4,31 @@ import type {
HouseholdSetupService,
HouseholdMiniAppAccess
} from '@household/application'
import { nowInstant } from '@household/domain'
import type { Logger } from '@household/observability'
import type { HouseholdConfigurationRepository } from '@household/ports'
import type {
HouseholdConfigurationRepository,
HouseholdTelegramChatRecord,
HouseholdTopicBindingRecord,
HouseholdTopicRole,
TelegramPendingActionRepository
} from '@household/ports'
import type { Bot, Context } from 'grammy'
import { getBotTranslations, type BotLocale } from './i18n'
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_CALLBACK_PREFIX = 'setup_topic:bind:'
const SETUP_BIND_TOPIC_ACTION = 'setup_topic_binding' as const
const SETUP_BIND_TOPIC_TTL_MS = 10 * 60 * 1000
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
'purchase',
'feedback',
'reminders',
'payments'
]
function commandArgText(ctx: Context): string {
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
@@ -28,6 +45,11 @@ function isTopicMessage(ctx: Context): boolean {
return !!message && 'is_topic_message' in message && message.is_topic_message === true
}
function isCommandMessage(ctx: Context): boolean {
const text = ctx.msg?.text
return typeof text === 'string' && text.trimStart().startsWith('/')
}
async function isGroupAdmin(ctx: Context): Promise<boolean> {
if (!ctx.chat || !ctx.from) {
return false
@@ -164,6 +186,136 @@ function pendingMembersReply(
} as const
}
function topicBindingDisplay(binding: HouseholdTopicBindingRecord): string {
return binding.topicName?.trim() || `thread ${binding.telegramThreadId}`
}
function setupTopicRoleLabel(locale: BotLocale, role: HouseholdTopicRole): string {
return getBotTranslations(locale).setup.setupTopicBindRoleName(role)
}
function setupSuggestedTopicName(locale: BotLocale, role: HouseholdTopicRole): string {
return getBotTranslations(locale).setup.setupTopicSuggestedName(role)
}
function setupKeyboard(input: {
locale: BotLocale
joinDeepLink: string | null
bindings: readonly HouseholdTopicBindingRecord[]
}) {
const t = getBotTranslations(input.locale).setup
const configuredRoles = new Set(input.bindings.map((binding) => binding.role))
const rows: Array<
Array<
| {
text: string
url: string
}
| {
text: string
callback_data: string
}
>
> = []
if (input.joinDeepLink) {
rows.push([
{
text: t.joinHouseholdButton,
url: input.joinDeepLink
}
])
}
for (const role of HOUSEHOLD_TOPIC_ROLE_ORDER) {
if (configuredRoles.has(role)) {
continue
}
rows.push([
{
text: t.setupTopicCreateButton(setupTopicRoleLabel(input.locale, role)),
callback_data: `${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}${role}`
},
{
text: t.setupTopicBindButton(setupTopicRoleLabel(input.locale, role)),
callback_data: `${SETUP_BIND_TOPIC_CALLBACK_PREFIX}${role}`
}
])
}
return rows.length > 0
? {
reply_markup: {
inline_keyboard: rows
}
}
: {}
}
function setupTopicChecklist(input: {
locale: BotLocale
bindings: readonly HouseholdTopicBindingRecord[]
}): string {
const t = getBotTranslations(input.locale).setup
const bindingByRole = new Map(input.bindings.map((binding) => [binding.role, binding]))
return [
t.setupTopicsHeading,
...HOUSEHOLD_TOPIC_ROLE_ORDER.map((role) => {
const binding = bindingByRole.get(role)
const roleLabel = setupTopicRoleLabel(input.locale, role)
return binding
? t.setupTopicBound(roleLabel, topicBindingDisplay(binding))
: t.setupTopicMissing(roleLabel)
})
].join('\n')
}
function setupReply(input: {
locale: BotLocale
household: HouseholdTelegramChatRecord
created: boolean
joinDeepLink: string | null
bindings: readonly HouseholdTopicBindingRecord[]
}) {
const t = getBotTranslations(input.locale).setup
return {
text: [
t.setupSummary({
householdName: input.household.householdName,
telegramChatId: input.household.telegramChatId,
created: input.created
}),
setupTopicChecklist({
locale: input.locale,
bindings: input.bindings
})
].join('\n\n'),
...setupKeyboard({
locale: input.locale,
joinDeepLink: input.joinDeepLink,
bindings: input.bindings
})
}
}
function isHouseholdTopicRole(value: string): value is HouseholdTopicRole {
return (
value === 'purchase' || value === 'feedback' || value === 'reminders' || value === 'payments'
)
}
function parseSetupBindPayload(payload: Record<string, unknown>): {
role: HouseholdTopicRole
} | null {
return typeof payload.role === 'string' && isHouseholdTopicRole(payload.role)
? {
role: payload.role
}
: null
}
export function buildJoinMiniAppUrl(
miniAppUrl: string | undefined,
botUsername: string | undefined,
@@ -216,10 +368,45 @@ export function registerHouseholdSetupCommands(options: {
householdSetupService: HouseholdSetupService
householdOnboardingService: HouseholdOnboardingService
householdAdminService: HouseholdAdminService
promptRepository?: TelegramPendingActionRepository
householdConfigurationRepository?: HouseholdConfigurationRepository
miniAppUrl?: string
logger?: Logger
}): void {
async function buildSetupReplyForHousehold(input: {
ctx: Context
locale: BotLocale
household: HouseholdTelegramChatRecord
created: boolean
}) {
const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({
householdId: input.household.householdId,
...(input.ctx.from?.id
? {
actorTelegramUserId: input.ctx.from.id.toString()
}
: {})
})
const joinDeepLink = input.ctx.me.username
? `https://t.me/${input.ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
: null
const bindings = options.householdConfigurationRepository
? await options.householdConfigurationRepository.listHouseholdTopicBindings(
input.household.householdId
)
: []
return setupReply({
locale: input.locale,
household: input.household,
created: input.created,
joinDeepLink,
bindings
})
}
async function handleBindTopicCommand(
ctx: Context,
role: 'purchase' | 'feedback' | 'reminders' | 'payments'
@@ -277,6 +464,67 @@ export function registerHouseholdSetupCommands(options: {
)
}
if (options.promptRepository) {
const promptRepository = options.promptRepository
options.bot.on('message', async (ctx, next) => {
if (!isGroupChat(ctx) || !isTopicMessage(ctx) || isCommandMessage(ctx)) {
await next()
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
const telegramThreadId =
ctx.msg && 'message_thread_id' in ctx.msg ? ctx.msg.message_thread_id?.toString() : null
if (!telegramUserId || !telegramChatId || !telegramThreadId) {
await next()
return
}
const pending = await promptRepository.getPendingAction(telegramChatId, telegramUserId)
if (pending?.action !== SETUP_BIND_TOPIC_ACTION) {
await next()
return
}
const payload = parseSetupBindPayload(pending.payload)
if (!payload) {
await promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await next()
return
}
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const result = await options.householdSetupService.bindTopic({
actorIsAdmin: await isGroupAdmin(ctx),
telegramChatId,
telegramThreadId,
role: payload.role
})
await promptRepository.clearPendingAction(telegramChatId, telegramUserId)
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(locale, result.reason))
return
}
await ctx.reply(
bindTopicSuccessMessage(
locale,
payload.role,
result.household.householdName,
result.binding.telegramThreadId
)
)
})
}
options.bot.command('start', async (ctx) => {
const fallbackLocale = await resolveReplyLocale({
ctx,
@@ -411,40 +659,13 @@ export function registerHouseholdSetupCommands(options: {
'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 reply = await buildSetupReplyForHousehold({
ctx,
locale,
household: result.household,
created: result.status === 'created'
})
const joinDeepLink = ctx.me.username
? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
: null
await ctx.reply(
t.setup.setupSummary({
householdName: result.household.householdName,
telegramChatId: result.household.telegramChatId,
created: action === 'created'
}),
joinDeepLink
? {
reply_markup: {
inline_keyboard: [
[
{
text: t.setup.joinHouseholdButton,
url: joinDeepLink
}
]
]
}
}
: {}
)
await ctx.reply(reply.text, 'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {})
})
options.bot.command('bind_purchase_topic', async (ctx) => {
@@ -606,6 +827,153 @@ export function registerHouseholdSetupCommands(options: {
await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName))
}
)
if (options.promptRepository) {
const promptRepository = options.promptRepository
options.bot.callbackQuery(
new RegExp(`^${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`),
async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).setup
if (!isGroupChat(ctx)) {
await ctx.answerCallbackQuery({
text: t.useButtonInGroup,
show_alert: true
})
return
}
const role = ctx.match[1] as HouseholdTopicRole
const telegramChatId = ctx.chat.id.toString()
const actorIsAdmin = await isGroupAdmin(ctx)
const household = options.householdConfigurationRepository
? await options.householdConfigurationRepository.getTelegramHouseholdChat(telegramChatId)
: null
if (!actorIsAdmin) {
await ctx.answerCallbackQuery({
text: t.onlyTelegramAdminsBindTopics,
show_alert: true
})
return
}
if (!household) {
await ctx.answerCallbackQuery({
text: t.householdNotConfigured,
show_alert: true
})
return
}
try {
const topicName = setupSuggestedTopicName(locale, role)
const createdTopic = await ctx.api.createForumTopic(ctx.chat.id, topicName)
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId,
telegramThreadId: createdTopic.message_thread_id.toString(),
role,
topicName
})
if (result.status === 'rejected') {
await ctx.answerCallbackQuery({
text: bindRejectionMessage(locale, result.reason),
show_alert: true
})
return
}
const reply = await buildSetupReplyForHousehold({
ctx,
locale,
household: result.household,
created: false
})
await ctx.answerCallbackQuery({
text: t.setupTopicCreated(setupTopicRoleLabel(locale, role), topicName)
})
if (ctx.msg) {
await ctx.editMessageText(
reply.text,
'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {}
)
}
} catch (error) {
const message =
error instanceof Error &&
/not enough rights|forbidden|admin|permission/i.test(error.message)
? t.setupTopicCreateForbidden
: t.setupTopicCreateFailed
await ctx.answerCallbackQuery({
text: message,
show_alert: true
})
}
}
)
options.bot.callbackQuery(
new RegExp(`^${SETUP_BIND_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`),
async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).setup
if (!isGroupChat(ctx)) {
await ctx.answerCallbackQuery({
text: t.useButtonInGroup,
show_alert: true
})
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat.id.toString()
const role = ctx.match[1] as HouseholdTopicRole
if (!telegramUserId) {
await ctx.answerCallbackQuery({
text: t.unableToIdentifySelectedMember,
show_alert: true
})
return
}
if (!(await isGroupAdmin(ctx))) {
await ctx.answerCallbackQuery({
text: t.onlyTelegramAdminsBindTopics,
show_alert: true
})
return
}
await promptRepository.upsertPendingAction({
telegramUserId,
telegramChatId,
action: SETUP_BIND_TOPIC_ACTION,
payload: {
role
},
expiresAt: nowInstant().add({ milliseconds: SETUP_BIND_TOPIC_TTL_MS })
})
await ctx.answerCallbackQuery({
text: t.setupTopicBindPending(setupTopicRoleLabel(locale, role))
})
}
)
}
}
function localeFromAccess(access: HouseholdMiniAppAccess, fallback: BotLocale): BotLocale {

View File

@@ -53,9 +53,47 @@ export const enBotTranslations: BotTranslationCatalog = {
[
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
`Chat ID: ${telegramChatId}`,
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want dedicated reminders or payments topics, open them and run /bind_reminders_topic or /bind_payments_topic.',
'Use the buttons below to finish topic setup. For an existing topic, tap Bind and then send any message inside that topic.',
'Members should open the bot chat from the button below and confirm the join request there.'
].join('\n'),
setupTopicsHeading: 'Topic setup:',
setupTopicBound: (role, topic) => `- ${role}: bound to ${topic}`,
setupTopicMissing: (role) => `- ${role}: not configured`,
setupTopicCreateButton: (role) => `Create ${role}`,
setupTopicBindButton: (role) => `Bind ${role}`,
setupTopicCreateFailed:
'I could not create that topic. Check bot admin permissions and forum settings.',
setupTopicCreateForbidden:
'I need permission to manage topics in this group before I can create one automatically.',
setupTopicCreated: (role, topicName) => `${role} topic created and bound: ${topicName}.`,
setupTopicBindPending: (role) =>
`Binding mode is on for ${role}. Open the target topic and send any message there within 10 minutes.`,
setupTopicBindCancelled: 'Topic binding mode cleared.',
setupTopicBindNotAvailable: 'That topic-binding action is no longer available.',
setupTopicBindRoleName: (role) => {
switch (role) {
case 'purchase':
return 'purchases'
case 'feedback':
return 'feedback'
case 'reminders':
return 'reminders'
case 'payments':
return 'payments'
}
},
setupTopicSuggestedName: (role) => {
switch (role) {
case 'purchase':
return 'Shared purchases'
case 'feedback':
return 'Anonymous feedback'
case 'reminders':
return 'Reminders'
case 'payments':
return 'Payments'
}
},
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
purchaseTopicSaved: (householdName, threadId) =>
`Purchase topic saved for ${householdName} (thread ${threadId}).`,

View File

@@ -55,9 +55,47 @@ export const ruBotTranslations: BotTranslationCatalog = {
[
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
`ID чата: ${telegramChatId}`,
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельные топики для напоминаний или оплат, откройте их и выполните /bind_reminders_topic или /bind_payments_topic.',
'Используйте кнопки ниже, чтобы завершить настройку топиков. Для уже существующего топика нажмите «Привязать», затем отправьте любое сообщение внутри этого топика.',
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
].join('\n'),
setupTopicsHeading: 'Настройка топиков:',
setupTopicBound: (role, topic) => `- ${role}: привязан к ${topic}`,
setupTopicMissing: (role) => `- ${role}: не настроен`,
setupTopicCreateButton: (role) => `Создать ${role}`,
setupTopicBindButton: (role) => `Привязать ${role}`,
setupTopicCreateFailed:
'Не удалось создать этот топик. Проверьте права бота и включённые форум-топики в группе.',
setupTopicCreateForbidden:
'Мне нужны права на управление топиками в этой группе, чтобы создать его автоматически.',
setupTopicCreated: (role, topicName) => `Топик ${role} создан и привязан: ${topicName}.`,
setupTopicBindPending: (role) =>
`Режим привязки включён для ${role}. Откройте нужный топик и отправьте там любое сообщение в течение 10 минут.`,
setupTopicBindCancelled: 'Режим привязки топика очищен.',
setupTopicBindNotAvailable: 'Это действие привязки топика уже недоступно.',
setupTopicBindRoleName: (role) => {
switch (role) {
case 'purchase':
return 'покупки'
case 'feedback':
return 'обратной связи'
case 'reminders':
return 'напоминаний'
case 'payments':
return 'оплат'
}
},
setupTopicSuggestedName: (role) => {
switch (role) {
case 'purchase':
return 'Общие покупки'
case 'feedback':
return 'Анонимная обратная связь'
case 'reminders':
return 'Напоминания'
case 'payments':
return 'Оплаты'
}
},
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
purchaseTopicSaved: (householdName, threadId) =>
`Топик покупок сохранён для ${householdName} (тред ${threadId}).`,

View File

@@ -73,6 +73,19 @@ export interface BotTranslationCatalog {
telegramChatId: string
created: boolean
}) => string
setupTopicsHeading: string
setupTopicBound: (role: string, topic: string) => string
setupTopicMissing: (role: string) => string
setupTopicCreateButton: (role: string) => string
setupTopicBindButton: (role: string) => string
setupTopicCreateFailed: string
setupTopicCreateForbidden: string
setupTopicCreated: (role: string, topicName: string) => string
setupTopicBindPending: (role: string) => string
setupTopicBindCancelled: string
setupTopicBindNotAvailable: string
setupTopicBindRoleName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string
setupTopicSuggestedName: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string
useBindPurchaseTopicInGroup: string
purchaseTopicSaved: (householdName: string, threadId: string) => string
useBindFeedbackTopicInGroup: string

View File

@@ -282,6 +282,11 @@ if (householdConfigurationRepositoryClient) {
),
householdOnboardingService: householdOnboardingService!,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
...(telegramPendingActionRepositoryClient
? {
promptRepository: telegramPendingActionRepositoryClient.repository
}
: {}),
...(runtime.miniAppAllowedOrigins[0]
? {
miniAppUrl: runtime.miniAppAllowedOrigins[0]