mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(bot): add guided setup topic binding flow
This commit is contained in:
@@ -5,6 +5,18 @@ import type {
|
|||||||
HouseholdOnboardingService,
|
HouseholdOnboardingService,
|
||||||
HouseholdSetupService
|
HouseholdSetupService
|
||||||
} from '@household/application'
|
} 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 { createTelegramBot } from './bot'
|
||||||
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
|
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
|
||||||
@@ -43,7 +55,7 @@ function startUpdate(text: string, languageCode?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHouseholdSetupService(): HouseholdSetupService {
|
function createRejectedHouseholdSetupService(): HouseholdSetupService {
|
||||||
return {
|
return {
|
||||||
async setupGroupChat() {
|
async setupGroupChat() {
|
||||||
return {
|
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', () => {
|
describe('buildJoinMiniAppUrl', () => {
|
||||||
test('adds join token and bot username query parameters', () => {
|
test('adds join token and bot username query parameters', () => {
|
||||||
const url = buildJoinMiniAppUrl(
|
const url = buildJoinMiniAppUrl(
|
||||||
@@ -156,7 +554,7 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
|
|
||||||
registerHouseholdSetupCommands({
|
registerHouseholdSetupCommands({
|
||||||
bot,
|
bot,
|
||||||
householdSetupService: createHouseholdSetupService(),
|
householdSetupService: createRejectedHouseholdSetupService(),
|
||||||
householdOnboardingService,
|
householdOnboardingService,
|
||||||
householdAdminService: createHouseholdAdminService(),
|
householdAdminService: createHouseholdAdminService(),
|
||||||
miniAppUrl: 'https://miniapp.example.app'
|
miniAppUrl: 'https://miniapp.example.app'
|
||||||
@@ -246,7 +644,7 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
|
|
||||||
registerHouseholdSetupCommands({
|
registerHouseholdSetupCommands({
|
||||||
bot,
|
bot,
|
||||||
householdSetupService: createHouseholdSetupService(),
|
householdSetupService: createRejectedHouseholdSetupService(),
|
||||||
householdOnboardingService,
|
householdOnboardingService,
|
||||||
householdAdminService: createHouseholdAdminService(),
|
householdAdminService: createHouseholdAdminService(),
|
||||||
miniAppUrl: 'https://miniapp.example.app'
|
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,
|
HouseholdSetupService,
|
||||||
HouseholdMiniAppAccess
|
HouseholdMiniAppAccess
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
|
import { nowInstant } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
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 type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
import { getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
import { resolveReplyLocale } from './bot-locale'
|
import { resolveReplyLocale } from './bot-locale'
|
||||||
|
|
||||||
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
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 {
|
function commandArgText(ctx: Context): string {
|
||||||
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
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
|
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> {
|
async function isGroupAdmin(ctx: Context): Promise<boolean> {
|
||||||
if (!ctx.chat || !ctx.from) {
|
if (!ctx.chat || !ctx.from) {
|
||||||
return false
|
return false
|
||||||
@@ -164,6 +186,136 @@ function pendingMembersReply(
|
|||||||
} as const
|
} 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(
|
export function buildJoinMiniAppUrl(
|
||||||
miniAppUrl: string | undefined,
|
miniAppUrl: string | undefined,
|
||||||
botUsername: string | undefined,
|
botUsername: string | undefined,
|
||||||
@@ -216,10 +368,45 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
householdSetupService: HouseholdSetupService
|
householdSetupService: HouseholdSetupService
|
||||||
householdOnboardingService: HouseholdOnboardingService
|
householdOnboardingService: HouseholdOnboardingService
|
||||||
householdAdminService: HouseholdAdminService
|
householdAdminService: HouseholdAdminService
|
||||||
|
promptRepository?: TelegramPendingActionRepository
|
||||||
householdConfigurationRepository?: HouseholdConfigurationRepository
|
householdConfigurationRepository?: HouseholdConfigurationRepository
|
||||||
miniAppUrl?: string
|
miniAppUrl?: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): 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(
|
async function handleBindTopicCommand(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
role: 'purchase' | 'feedback' | 'reminders' | 'payments'
|
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) => {
|
options.bot.command('start', async (ctx) => {
|
||||||
const fallbackLocale = await resolveReplyLocale({
|
const fallbackLocale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -411,40 +659,13 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
'Household group registered'
|
'Household group registered'
|
||||||
)
|
)
|
||||||
|
|
||||||
const action = result.status === 'created' ? 'created' : 'already registered'
|
const reply = await buildSetupReplyForHousehold({
|
||||||
const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({
|
ctx,
|
||||||
householdId: result.household.householdId,
|
locale,
|
||||||
...(ctx.from?.id
|
household: result.household,
|
||||||
? {
|
created: result.status === 'created'
|
||||||
actorTelegramUserId: ctx.from.id.toString()
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
})
|
})
|
||||||
|
await ctx.reply(reply.text, 'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {})
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('bind_purchase_topic', async (ctx) => {
|
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))
|
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 {
|
function localeFromAccess(access: HouseholdMiniAppAccess, fallback: BotLocale): BotLocale {
|
||||||
|
|||||||
@@ -53,9 +53,47 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
[
|
[
|
||||||
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
||||||
`Chat ID: ${telegramChatId}`,
|
`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.'
|
'Members should open the bot chat from the button below and confirm the join request there.'
|
||||||
].join('\n'),
|
].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.',
|
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
|
||||||
purchaseTopicSaved: (householdName, threadId) =>
|
purchaseTopicSaved: (householdName, threadId) =>
|
||||||
`Purchase topic saved for ${householdName} (thread ${threadId}).`,
|
`Purchase topic saved for ${householdName} (thread ${threadId}).`,
|
||||||
|
|||||||
@@ -55,9 +55,47 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
[
|
[
|
||||||
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
||||||
`ID чата: ${telegramChatId}`,
|
`ID чата: ${telegramChatId}`,
|
||||||
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельные топики для напоминаний или оплат, откройте их и выполните /bind_reminders_topic или /bind_payments_topic.',
|
'Используйте кнопки ниже, чтобы завершить настройку топиков. Для уже существующего топика нажмите «Привязать», затем отправьте любое сообщение внутри этого топика.',
|
||||||
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
||||||
].join('\n'),
|
].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 внутри топика группы дома.',
|
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
|
||||||
purchaseTopicSaved: (householdName, threadId) =>
|
purchaseTopicSaved: (householdName, threadId) =>
|
||||||
`Топик покупок сохранён для ${householdName} (тред ${threadId}).`,
|
`Топик покупок сохранён для ${householdName} (тред ${threadId}).`,
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ export interface BotTranslationCatalog {
|
|||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
created: boolean
|
created: boolean
|
||||||
}) => string
|
}) => 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
|
useBindPurchaseTopicInGroup: string
|
||||||
purchaseTopicSaved: (householdName: string, threadId: string) => string
|
purchaseTopicSaved: (householdName: string, threadId: string) => string
|
||||||
useBindFeedbackTopicInGroup: string
|
useBindFeedbackTopicInGroup: string
|
||||||
|
|||||||
@@ -282,6 +282,11 @@ if (householdConfigurationRepositoryClient) {
|
|||||||
),
|
),
|
||||||
householdOnboardingService: householdOnboardingService!,
|
householdOnboardingService: householdOnboardingService!,
|
||||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
|
...(telegramPendingActionRepositoryClient
|
||||||
|
? {
|
||||||
|
promptRepository: telegramPendingActionRepositoryClient.repository
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(runtime.miniAppAllowedOrigins[0]
|
...(runtime.miniAppAllowedOrigins[0]
|
||||||
? {
|
? {
|
||||||
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw === 'setup_topic_binding') {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unexpected telegram pending action type: ${raw}`)
|
throw new Error(`Unexpected telegram pending action type: ${raw}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { Instant } from '@household/domain'
|
|||||||
|
|
||||||
export const TELEGRAM_PENDING_ACTION_TYPES = [
|
export const TELEGRAM_PENDING_ACTION_TYPES = [
|
||||||
'anonymous_feedback',
|
'anonymous_feedback',
|
||||||
'assistant_payment_confirmation'
|
'assistant_payment_confirmation',
|
||||||
|
'setup_topic_binding'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
||||||
|
|||||||
Reference in New Issue
Block a user