mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:04:02 +00:00
feat(bot): add guided setup topic binding flow
This commit is contained in:
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}).`,
|
||||
|
||||
@@ -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}).`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -282,6 +282,11 @@ if (householdConfigurationRepositoryClient) {
|
||||
),
|
||||
householdOnboardingService: householdOnboardingService!,
|
||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||
...(telegramPendingActionRepositoryClient
|
||||
? {
|
||||
promptRepository: telegramPendingActionRepositoryClient.repository
|
||||
}
|
||||
: {}),
|
||||
...(runtime.miniAppAllowedOrigins[0]
|
||||
? {
|
||||
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
||||
|
||||
Reference in New Issue
Block a user