feat(bot): cut over multi-household member flows

This commit is contained in:
2026-03-09 06:14:57 +04:00
parent de86706f4f
commit 7c602900ee
20 changed files with 1068 additions and 163 deletions

View File

@@ -1,7 +1,10 @@
import { describe, expect, mock, test } from 'bun:test'
import type { AnonymousFeedbackService } from '@household/application'
import type { TelegramPendingActionRepository } from '@household/ports'
import type {
HouseholdConfigurationRepository,
TelegramPendingActionRepository
} from '@household/ports'
import { createTelegramBot } from './bot'
import { registerAnonymousFeedback } from './anonymous-feedback'
@@ -76,6 +79,89 @@ function createPromptRepository(): TelegramPendingActionRepository {
}
}
function createHouseholdConfigurationRepository(): HouseholdConfigurationRepository {
return {
registerTelegramHouseholdChat: async () => ({
status: 'existing',
household: {
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100222333',
telegramChatType: 'supergroup',
title: 'Kojori House'
}
}),
getTelegramHouseholdChat: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100222333',
telegramChatType: 'supergroup',
title: 'Kojori House'
}),
getHouseholdChatByHouseholdId: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100222333',
telegramChatType: 'supergroup',
title: 'Kojori House'
}),
bindHouseholdTopic: async (input) => ({
householdId: input.householdId,
role: input.role,
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null
}),
getHouseholdTopicBinding: async (_householdId, role) =>
role === 'feedback'
? {
householdId: 'household-1',
role: 'feedback',
telegramThreadId: '77',
topicName: 'Feedback'
}
: null,
findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [],
upsertHouseholdJoinToken: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token',
createdByTelegramUserId: null
}),
getHouseholdJoinToken: async () => null,
getHouseholdByJoinToken: async () => null,
upsertPendingHouseholdMember: async (input) => ({
householdId: input.householdId,
householdName: 'Kojori House',
telegramUserId: input.telegramUserId,
displayName: input.displayName,
username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null
}),
getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null,
ensureHouseholdMember: async (input) => ({
id: `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
listHouseholdMembersByTelegramUserId: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: false
}
],
listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null
}
}
describe('registerAnonymousFeedback', () => {
test('posts accepted feedback into the configured topic', async () => {
const bot = createTelegramBot('000000:test-token')
@@ -124,10 +210,9 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({
bot,
anonymousFeedbackService,
promptRepository: createPromptRepository(),
householdChatId: '-100222333',
feedbackTopicId: 77
anonymousFeedbackServiceForHousehold: () => anonymousFeedbackService,
householdConfigurationRepository: createHouseholdConfigurationRepository(),
promptRepository: createPromptRepository()
})
await bot.handleUpdate(
@@ -187,7 +272,7 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({
bot,
anonymousFeedbackService: {
anonymousFeedbackServiceForHousehold: () => ({
submit: mock(async () => ({
status: 'accepted' as const,
submissionId: 'submission-1',
@@ -195,10 +280,9 @@ describe('registerAnonymousFeedback', () => {
})),
markPosted: mock(async () => {}),
markFailed: mock(async () => {})
},
promptRepository: createPromptRepository(),
householdChatId: '-100222333',
feedbackTopicId: 77
}),
householdConfigurationRepository: createHouseholdConfigurationRepository(),
promptRepository: createPromptRepository()
})
await bot.handleUpdate(
@@ -258,14 +342,13 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({
bot,
anonymousFeedbackService: {
anonymousFeedbackServiceForHousehold: () => ({
submit,
markPosted: mock(async () => {}),
markFailed: mock(async () => {})
},
promptRepository: createPromptRepository(),
householdChatId: '-100222333',
feedbackTopicId: 77
}),
householdConfigurationRepository: createHouseholdConfigurationRepository(),
promptRepository: createPromptRepository()
})
await bot.handleUpdate(
@@ -341,14 +424,13 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({
bot,
anonymousFeedbackService: {
anonymousFeedbackServiceForHousehold: () => ({
submit,
markPosted: mock(async () => {}),
markFailed: mock(async () => {})
},
promptRepository: createPromptRepository(),
householdChatId: '-100222333',
feedbackTopicId: 77
}),
householdConfigurationRepository: createHouseholdConfigurationRepository(),
promptRepository: createPromptRepository()
})
await bot.handleUpdate(

View File

@@ -1,6 +1,9 @@
import type { AnonymousFeedbackService } from '@household/application'
import type { Logger } from '@household/observability'
import type { TelegramPendingActionRepository } from '@household/ports'
import type {
HouseholdConfigurationRepository,
TelegramPendingActionRepository
} from '@household/ports'
import type { Bot, Context } from 'grammy'
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
@@ -98,10 +101,9 @@ async function startPendingAnonymousFeedbackPrompt(
async function submitAnonymousFeedback(options: {
ctx: Context
anonymousFeedbackService: AnonymousFeedbackService
anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository
householdChatId: string
feedbackTopicId: number
logger?: Logger | undefined
rawText: string
keepPromptOnValidationFailure?: boolean
@@ -117,7 +119,44 @@ async function submitAnonymousFeedback(options: {
return
}
const result = await options.anonymousFeedbackService.submit({
const memberships =
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
telegramUserId
)
if (memberships.length === 0) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply('You are not a member of this household.')
return
}
if (memberships.length > 1) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(
'You belong to multiple households. Open the target household from its group until household selection is added.'
)
return
}
const member = memberships[0]!
const householdChat =
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
member.householdId,
'feedback'
)
if (!householdChat || !feedbackTopic) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(
'Anonymous feedback is not configured for your household yet. Ask an admin to run /bind_feedback_topic.'
)
return
}
const anonymousFeedbackService = options.anonymousFeedbackServiceForHousehold(member.householdId)
const result = await anonymousFeedbackService.submit({
telegramUserId,
rawText: options.rawText,
telegramChatId,
@@ -151,17 +190,17 @@ async function submitAnonymousFeedback(options: {
try {
const posted = await options.ctx.api.sendMessage(
options.householdChatId,
householdChat.telegramChatId,
feedbackText(result.sanitizedText),
{
message_thread_id: options.feedbackTopicId
message_thread_id: Number(feedbackTopic.telegramThreadId)
}
)
await options.anonymousFeedbackService.markPosted({
await anonymousFeedbackService.markPosted({
submissionId: result.submissionId,
postedChatId: options.householdChatId,
postedThreadId: options.feedbackTopicId.toString(),
postedChatId: householdChat.telegramChatId,
postedThreadId: feedbackTopic.telegramThreadId,
postedMessageId: posted.message_id.toString()
})
@@ -173,23 +212,22 @@ async function submitAnonymousFeedback(options: {
{
event: 'anonymous_feedback.post_failed',
submissionId: result.submissionId,
householdChatId: options.householdChatId,
feedbackTopicId: options.feedbackTopicId,
householdChatId: householdChat.telegramChatId,
feedbackTopicId: feedbackTopic.telegramThreadId,
error: message
},
'Anonymous feedback posting failed'
)
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
await anonymousFeedbackService.markFailed(result.submissionId, message)
await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
}
}
export function registerAnonymousFeedback(options: {
bot: Bot
anonymousFeedbackService: AnonymousFeedbackService
anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository
householdChatId: string
feedbackTopicId: number
logger?: Logger
}): void {
options.bot.command('cancel', async (ctx) => {
@@ -228,10 +266,9 @@ export function registerAnonymousFeedback(options: {
await submitAnonymousFeedback({
ctx,
anonymousFeedbackService: options.anonymousFeedbackService,
anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: options.householdConfigurationRepository,
promptRepository: options.promptRepository,
householdChatId: options.householdChatId,
feedbackTopicId: options.feedbackTopicId,
logger: options.logger,
rawText
})
@@ -258,10 +295,9 @@ export function registerAnonymousFeedback(options: {
await submitAnonymousFeedback({
ctx,
anonymousFeedbackService: options.anonymousFeedbackService,
anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: options.householdConfigurationRepository,
promptRepository: options.promptRepository,
householdChatId: options.householdChatId,
feedbackTopicId: options.feedbackTopicId,
logger: options.logger,
rawText: ctx.msg.text,
keepPromptOnValidationFailure: true

View File

@@ -102,18 +102,10 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
const purchaseTopicIngestionEnabled =
databaseUrl !== undefined &&
householdId !== undefined &&
telegramHouseholdChatId !== undefined &&
telegramPurchaseTopicId !== undefined
const purchaseTopicIngestionEnabled = databaseUrl !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
const anonymousFeedbackEnabled =
databaseUrl !== undefined &&
householdId !== undefined &&
telegramHouseholdChatId !== undefined &&
telegramFeedbackTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined
const anonymousFeedbackEnabled = databaseUrl !== undefined
const miniAppAuthEnabled = databaseUrl !== undefined
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
const reminderJobsEnabled =

View File

@@ -1,4 +1,5 @@
import type { FinanceCommandService } from '@household/application'
import type { HouseholdConfigurationRepository } from '@household/ports'
import type { Bot, Context } from 'grammy'
function commandArgs(ctx: Context): string[] {
@@ -10,9 +11,39 @@ function commandArgs(ctx: Context): string[] {
return raw.split(/\s+/).filter(Boolean)
}
export function createFinanceCommandsService(financeService: FinanceCommandService): {
function isGroupChat(ctx: Context): boolean {
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
}
export function createFinanceCommandsService(options: {
householdConfigurationRepository: HouseholdConfigurationRepository
financeServiceForHousehold: (householdId: string) => FinanceCommandService
}): {
register: (bot: Bot) => void
} {
async function resolveGroupFinanceService(ctx: Context): Promise<{
service: FinanceCommandService
householdId: string
} | null> {
if (!isGroupChat(ctx)) {
await ctx.reply('Use this command inside a household group.')
return null
}
const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(
ctx.chat!.id.toString()
)
if (!household) {
await ctx.reply('Household is not configured for this chat yet. Run /setup first.')
return null
}
return {
service: options.financeServiceForHousehold(household.householdId),
householdId: household.householdId
}
}
async function requireMember(ctx: Context) {
const telegramUserId = ctx.from?.id?.toString()
if (!telegramUserId) {
@@ -20,33 +51,42 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
return null
}
const member = await financeService.getMemberByTelegramUserId(telegramUserId)
const scoped = await resolveGroupFinanceService(ctx)
if (!scoped) {
return null
}
const member = await scoped.service.getMemberByTelegramUserId(telegramUserId)
if (!member) {
await ctx.reply('You are not a member of this household.')
return null
}
return member
return {
member,
service: scoped.service,
householdId: scoped.householdId
}
}
async function requireAdmin(ctx: Context) {
const member = await requireMember(ctx)
if (!member) {
const resolved = await requireMember(ctx)
if (!resolved) {
return null
}
if (!member.isAdmin) {
if (!resolved.member.isAdmin) {
await ctx.reply('Only household admins can use this command.')
return null
}
return member
return resolved
}
function register(bot: Bot): void {
bot.command('cycle_open', async (ctx) => {
const admin = await requireAdmin(ctx)
if (!admin) {
const resolved = await requireAdmin(ctx)
if (!resolved) {
return
}
@@ -57,7 +97,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}
try {
const cycle = await financeService.openCycle(args[0]!, args[1])
const cycle = await resolved.service.openCycle(args[0]!, args[1])
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
} catch (error) {
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
@@ -65,13 +105,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
})
bot.command('cycle_close', async (ctx) => {
const admin = await requireAdmin(ctx)
if (!admin) {
const resolved = await requireAdmin(ctx)
if (!resolved) {
return
}
try {
const cycle = await financeService.closeCycle(commandArgs(ctx)[0])
const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0])
if (!cycle) {
await ctx.reply('No cycle found to close.')
return
@@ -84,8 +124,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
})
bot.command('rent_set', async (ctx) => {
const admin = await requireAdmin(ctx)
if (!admin) {
const resolved = await requireAdmin(ctx)
if (!resolved) {
return
}
@@ -96,7 +136,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}
try {
const result = await financeService.setRent(args[0]!, args[1], args[2])
const result = await resolved.service.setRent(args[0]!, args[1], args[2])
if (!result) {
await ctx.reply('No period provided and no open cycle found.')
return
@@ -111,8 +151,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
})
bot.command('utility_add', async (ctx) => {
const admin = await requireAdmin(ctx)
if (!admin) {
const resolved = await requireAdmin(ctx)
if (!resolved) {
return
}
@@ -123,7 +163,12 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}
try {
const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2])
const result = await resolved.service.addUtilityBill(
args[0]!,
args[1]!,
resolved.member.id,
args[2]
)
if (!result) {
await ctx.reply('No open cycle found. Use /cycle_open first.')
return
@@ -138,13 +183,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
})
bot.command('statement', async (ctx) => {
const member = await requireMember(ctx)
if (!member) {
const resolved = await requireMember(ctx)
if (!resolved) {
return
}
try {
const statement = await financeService.generateStatement(commandArgs(ctx)[0])
const statement = await resolved.service.generateStatement(commandArgs(ctx)[0])
if (!statement) {
await ctx.reply('No cycle found for statement.')
return

View File

@@ -0,0 +1,180 @@
import { describe, expect, test } from 'bun:test'
import type {
HouseholdAdminService,
HouseholdOnboardingService,
HouseholdSetupService
} from '@household/application'
import { createTelegramBot } from './bot'
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
function startUpdate(text: string) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 2001,
message: {
message_id: 71,
date: Math.floor(Date.now() / 1000),
chat: {
id: 123456,
type: 'private'
},
from: {
id: 123456,
is_bot: false,
first_name: 'Stan'
},
text,
entities: [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
}
}
}
function createHouseholdSetupService(): HouseholdSetupService {
return {
async setupGroupChat() {
return {
status: 'rejected',
reason: 'invalid_chat_type'
}
},
async bindTopic() {
return {
status: 'rejected',
reason: 'household_not_found'
}
}
}
}
function createHouseholdAdminService(): HouseholdAdminService {
return {
async listPendingMembers() {
return {
status: 'rejected',
reason: 'household_not_found'
}
},
async approvePendingMember() {
return {
status: 'rejected',
reason: 'pending_not_found'
}
}
}
}
describe('buildJoinMiniAppUrl', () => {
test('adds join token and bot username query parameters', () => {
const url = buildJoinMiniAppUrl(
'https://household-dev-mini-app.example.app',
'kojori_bot',
'join-token'
)
expect(url).toBe('https://household-dev-mini-app.example.app/?join=join-token&bot=kojori_bot')
})
test('returns null when no mini app url is configured', () => {
expect(buildJoinMiniAppUrl(undefined, 'kojori_bot', 'join-token')).toBeNull()
})
})
describe('registerHouseholdSetupCommands', () => {
test('offers an Open mini app button after a DM join request', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
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: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: 123456,
type: 'private'
},
text: 'ok'
}
} 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'
}
}
}
}
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
miniAppUrl: 'https://miniapp.example.app'
})
await bot.handleUpdate(startUpdate('/start join_join-token') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: 123456,
text: 'Join request sent for Kojori House. Wait for a household admin to confirm you.',
reply_markup: {
inline_keyboard: [
[
{
text: 'Open mini app',
web_app: {
url: 'https://miniapp.example.app/?join=join-token&bot=household_test_bot'
}
}
]
]
}
})
})
})

View File

@@ -112,11 +112,58 @@ function pendingMembersReply(result: {
} as const
}
export function buildJoinMiniAppUrl(
miniAppUrl: string | undefined,
botUsername: string | undefined,
joinToken: string
): string | null {
const normalizedMiniAppUrl = miniAppUrl?.trim()
if (!normalizedMiniAppUrl) {
return null
}
const url = new URL(normalizedMiniAppUrl)
url.searchParams.set('join', joinToken)
if (botUsername && botUsername.trim().length > 0) {
url.searchParams.set('bot', botUsername.trim())
}
return url.toString()
}
function miniAppReplyMarkup(
miniAppUrl: string | undefined,
botUsername: string | undefined,
joinToken: string
) {
const webAppUrl = buildJoinMiniAppUrl(miniAppUrl, botUsername, joinToken)
if (!webAppUrl) {
return {}
}
return {
reply_markup: {
inline_keyboard: [
[
{
text: 'Open mini app',
web_app: {
url: webAppUrl
}
}
]
]
}
}
}
export function registerHouseholdSetupCommands(options: {
bot: Bot
householdSetupService: HouseholdSetupService
householdOnboardingService: HouseholdOnboardingService
householdAdminService: HouseholdAdminService
miniAppUrl?: string
logger?: Logger
}): void {
options.bot.command('start', async (ctx) => {
@@ -171,13 +218,15 @@ export function registerHouseholdSetupCommands(options: {
if (result.status === 'active') {
await ctx.reply(
`You are already an active member. Open the mini app to view ${result.member.displayName}.`
`You are already an active member. Open the mini app to view ${result.member.displayName}.`,
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
)
return
}
await ctx.reply(
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`,
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
)
})

View File

@@ -47,46 +47,60 @@ const shutdownTasks: Array<() => Promise<void>> = []
const householdConfigurationRepositoryClient = runtime.databaseUrl
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
: null
const financeRepositoryClient =
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
: null
const financeService = financeRepositoryClient
? createFinanceCommandService(financeRepositoryClient.repository)
: null
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
const householdOnboardingService = householdConfigurationRepositoryClient
? createHouseholdOnboardingService({
repository: householdConfigurationRepositoryClient.repository,
...(financeRepositoryClient
? {
getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId
}
: {})
repository: householdConfigurationRepositoryClient.repository
})
: null
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
: null
const telegramPendingActionRepositoryClient =
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
: null
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
: null
const anonymousFeedbackRepositoryClients = new Map<
string,
ReturnType<typeof createDbAnonymousFeedbackRepository>
>()
const anonymousFeedbackServices = new Map<
string,
ReturnType<typeof createAnonymousFeedbackService>
>()
if (financeRepositoryClient) {
shutdownTasks.push(financeRepositoryClient.close)
function financeServiceForHousehold(householdId: string) {
const existing = financeServices.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
const service = createFinanceCommandService(repositoryClient.repository)
financeServices.set(householdId, service)
return service
}
function anonymousFeedbackServiceForHousehold(householdId: string) {
const existing = anonymousFeedbackServices.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbAnonymousFeedbackRepository(runtime.databaseUrl!, householdId)
anonymousFeedbackRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
const service = createAnonymousFeedbackService(repositoryClient.repository)
anonymousFeedbackServices.set(householdId, service)
return service
}
if (householdConfigurationRepositoryClient) {
shutdownTasks.push(householdConfigurationRepositoryClient.close)
}
if (anonymousFeedbackRepositoryClient) {
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
}
if (telegramPendingActionRepositoryClient) {
shutdownTasks.push(telegramPendingActionRepositoryClient.close)
}
@@ -120,7 +134,10 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
}
if (runtime.financeCommandsEnabled) {
const financeCommands = createFinanceCommandsService(financeService!)
const financeCommands = createFinanceCommandsService({
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
financeServiceForHousehold
})
financeCommands.register(bot)
} else {
@@ -129,7 +146,7 @@ if (runtime.financeCommandsEnabled) {
event: 'runtime.feature_disabled',
feature: 'finance-commands'
},
'Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.'
'Finance commands are disabled. Set DATABASE_URL to enable household lookups.'
)
}
@@ -143,6 +160,11 @@ if (householdConfigurationRepositoryClient) {
householdConfigurationRepositoryClient.repository
),
householdOnboardingService: householdOnboardingService!,
...(runtime.miniAppAllowedOrigins[0]
? {
miniAppUrl: runtime.miniAppAllowedOrigins[0]
}
: {}),
logger: getLogger('household-setup')
})
} else {
@@ -180,13 +202,16 @@ if (!runtime.reminderJobsEnabled) {
)
}
if (anonymousFeedbackService) {
if (
runtime.anonymousFeedbackEnabled &&
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient
) {
registerAnonymousFeedback({
bot,
anonymousFeedbackService,
anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
promptRepository: telegramPendingActionRepositoryClient!.repository,
householdChatId: runtime.telegramHouseholdChatId!,
feedbackTopicId: runtime.telegramFeedbackTopicId!,
logger: getLogger('anonymous-feedback')
})
} else {
@@ -195,7 +220,7 @@ if (anonymousFeedbackService) {
event: 'runtime.feature_disabled',
feature: 'anonymous-feedback'
},
'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.'
'Anonymous feedback is disabled. Set DATABASE_URL to enable household and topic lookups.'
)
}
@@ -219,11 +244,11 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-auth')
})
: undefined,
miniAppDashboard: financeService
miniAppDashboard: householdOnboardingService
? createMiniAppDashboardHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
financeService,
financeServiceForHousehold,
onboardingService: householdOnboardingService!,
logger: getLogger('miniapp-dashboard')
})

View File

@@ -18,6 +18,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
title: 'Kojori House'
}
let joinToken: string | null = 'join-token'
const members = new Map<
string,
{
id: string
householdId: string
telegramUserId: string
displayName: string
isAdmin: boolean
}
>()
let pending: {
householdId: string
householdName: string
@@ -33,6 +43,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
household
}),
getTelegramHouseholdChat: async () => household,
getHouseholdChatByHouseholdId: async () => household,
bindHouseholdTopic: async (input) =>
({
householdId: input.householdId,
@@ -72,13 +83,22 @@ function onboardingRepository(): HouseholdConfigurationRepository {
},
getPendingHouseholdMember: async () => pending,
findPendingHouseholdMemberByTelegramUserId: async () => pending,
ensureHouseholdMember: async (input) => ({
householdId: household.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
ensureHouseholdMember: async (input) => {
const member = {
id: `member-${input.telegramUserId}`,
householdId: household.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}
members.set(input.telegramUserId, member)
return member
},
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
listHouseholdMembersByTelegramUserId: async (telegramUserId) => {
const member = members.get(telegramUserId)
return member ? [member] : []
},
listPendingHouseholdMembers: async () => (pending ? [pending] : []),
approvePendingHouseholdMember: async (input) => {
if (!pending || pending.telegramUserId !== input.telegramUserId) {
@@ -86,11 +106,13 @@ function onboardingRepository(): HouseholdConfigurationRepository {
}
const member = {
id: `member-${pending.telegramUserId}`,
householdId: household.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
isAdmin: input.isAdmin === true
}
members.set(pending.telegramUserId, member)
pending = null
return member
}
@@ -100,17 +122,18 @@ function onboardingRepository(): HouseholdConfigurationRepository {
describe('createMiniAppAuthHandler', () => {
test('returns an authorized session for a household member', async () => {
const authDate = Math.floor(Date.now() / 1000)
const repository = onboardingRepository()
await repository.ensureHouseholdMember({
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
})
const auth = createMiniAppAuthHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository: onboardingRepository(),
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
})
repository
})
})

View File

@@ -98,6 +98,7 @@ export interface MiniAppSessionResult {
authorized: boolean
member?: {
id: string
householdId: string
displayName: string
isAdmin: boolean
}

View File

@@ -84,6 +84,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
household
}),
getTelegramHouseholdChat: async () => household,
getHouseholdChatByHouseholdId: async () => household,
bindHouseholdTopic: async (input) =>
({
householdId: input.householdId,
@@ -113,12 +114,14 @@ function onboardingRepository(): HouseholdConfigurationRepository {
getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null,
ensureHouseholdMember: async (input) => ({
id: `member-${input.telegramUserId}`,
householdId: household.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null
}
@@ -135,14 +138,23 @@ describe('createMiniAppDashboardHandler', () => {
isAdmin: true
})
)
const householdRepository = onboardingRepository()
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-1',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
}
]
const dashboard = createMiniAppDashboardHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
financeService,
financeServiceForHousehold: () => financeService,
onboardingService: createHouseholdOnboardingService({
repository: onboardingRepository(),
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
repository: householdRepository
})
})
@@ -202,14 +214,23 @@ describe('createMiniAppDashboardHandler', () => {
isAdmin: true
})
)
const householdRepository = onboardingRepository()
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-1',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
}
]
const dashboard = createMiniAppDashboardHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
financeService,
financeServiceForHousehold: () => financeService,
onboardingService: createHouseholdOnboardingService({
repository: onboardingRepository(),
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
repository: householdRepository
})
})

View File

@@ -12,7 +12,7 @@ import {
export function createMiniAppDashboardHandler(options: {
allowedOrigins: readonly string[]
botToken: string
financeService: FinanceCommandService
financeServiceForHousehold: (householdId: string) => FinanceCommandService
onboardingService: HouseholdOnboardingService
logger?: Logger
}): {
@@ -62,7 +62,17 @@ export function createMiniAppDashboardHandler(options: {
)
}
const dashboard = await options.financeService.generateDashboard()
if (!session.member) {
return miniAppJsonResponse(
{ ok: false, error: 'Authenticated session is missing member context' },
500,
origin
)
}
const dashboard = await options
.financeServiceForHousehold(session.member.householdId)
.generateDashboard()
if (!dashboard) {
return miniAppJsonResponse(
{ ok: false, error: 'No billing cycle available' },

View File

@@ -1,8 +1,13 @@
import { describe, expect, test } from 'bun:test'
import { createTelegramBot } from './bot'
import {
buildPurchaseAcknowledgement,
extractPurchaseTopicCandidate,
registerPurchaseTopicIngestion,
resolveConfiguredPurchaseTopicRecord,
type PurchaseMessageIngestionRepository,
type PurchaseTopicCandidate
} from './purchase-topic-ingestion'
@@ -25,6 +30,39 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTop
}
}
function purchaseUpdate(text: string) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 1001,
message: {
message_id: 55,
date: Math.floor(Date.now() / 1000),
message_thread_id: 777,
is_topic_message: true,
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
from: {
id: 10002,
is_bot: false,
first_name: 'Mia'
},
text,
entities: text.startsWith('/')
? [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
: []
}
}
}
describe('extractPurchaseTopicCandidate', () => {
test('returns record when message belongs to configured topic', () => {
const record = extractPurchaseTopicCandidate(candidate(), config)
@@ -86,3 +124,169 @@ describe('resolveConfiguredPurchaseTopicRecord', () => {
expect(record).toBeNull()
})
})
describe('buildPurchaseAcknowledgement', () => {
test('returns parsed acknowledgement with amount summary', () => {
const result = buildPurchaseAcknowledgement({
status: 'created',
processingStatus: 'parsed',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'rules'
})
expect(result).toBe('Recorded purchase: toilet paper - 30.00 GEL')
})
test('returns review acknowledgement when parsing needs review', () => {
const result = buildPurchaseAcknowledgement({
status: 'created',
processingStatus: 'needs_review',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'shared purchase',
parserConfidence: 78,
parserMode: 'rules'
})
expect(result).toBe('Saved for review: shared purchase - 30.00 GEL')
})
test('returns parse failure acknowledgement without guessed values', () => {
const result = buildPurchaseAcknowledgement({
status: 'created',
processingStatus: 'parse_failed',
parsedAmountMinor: null,
parsedCurrency: null,
parsedItemDescription: null,
parserConfidence: null,
parserMode: null
})
expect(result).toBe("Saved for review: I couldn't parse this purchase yet.")
})
test('does not acknowledge duplicates', () => {
expect(
buildPurchaseAcknowledgement({
status: 'duplicate'
})
).toBeNull()
})
})
describe('registerPurchaseTopicIngestion', () => {
test('replies in-topic after a parsed purchase is recorded', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
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: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
return {
status: 'created',
processingStatus: 'parsed',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'rules'
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: Number(config.householdChatId),
reply_parameters: {
message_id: 55
},
text: 'Recorded purchase: toilet paper - 30.00 GEL'
})
})
test('does not reply for duplicate deliveries', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
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: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
return {
status: 'duplicate'
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(0)
})
})

View File

@@ -1,4 +1,5 @@
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
import { Money } from '@household/domain'
import { and, eq } from 'drizzle-orm'
import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability'
@@ -30,11 +31,27 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
householdId: string
}
export type PurchaseMessageProcessingStatus = 'parsed' | 'needs_review' | 'parse_failed'
export type PurchaseMessageIngestionResult =
| {
status: 'duplicate'
}
| {
status: 'created'
processingStatus: PurchaseMessageProcessingStatus
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
parserConfidence: number | null
parserMode: 'rules' | 'llm' | null
}
export interface PurchaseMessageIngestionRepository {
save(
record: PurchaseTopicRecord,
llmFallback?: PurchaseParserLlmFallback
): Promise<'created' | 'duplicate'>
): Promise<PurchaseMessageIngestionResult>
}
export function extractPurchaseTopicCandidate(
@@ -172,7 +189,21 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
})
.returning({ id: schema.purchaseMessages.id })
return inserted.length > 0 ? 'created' : 'duplicate'
if (inserted.length === 0) {
return {
status: 'duplicate'
}
}
return {
status: 'created',
processingStatus,
parsedAmountMinor: parsed?.amountMinor ?? null,
parsedCurrency: parsed?.currency ?? null,
parsedItemDescription: parsed?.itemDescription ?? null,
parserConfidence: parsed?.confidence ?? null,
parserMode: parsed?.parserMode ?? null
}
}
}
@@ -184,6 +215,51 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
}
}
function formatPurchaseSummary(
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }>
): string {
if (
result.parsedAmountMinor === null ||
result.parsedCurrency === null ||
result.parsedItemDescription === null
) {
return 'shared purchase'
}
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
}
export function buildPurchaseAcknowledgement(
result: PurchaseMessageIngestionResult
): string | null {
if (result.status === 'duplicate') {
return null
}
switch (result.processingStatus) {
case 'parsed':
return `Recorded purchase: ${formatPurchaseSummary(result)}`
case 'needs_review':
return `Saved for review: ${formatPurchaseSummary(result)}`
case 'parse_failed':
return "Saved for review: I couldn't parse this purchase yet."
}
}
async function replyToPurchaseMessage(ctx: Context, text: string): Promise<void> {
const message = ctx.msg
if (!message) {
return
}
await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
}
})
}
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
const message = ctx.message
if (!message || !('text' in message)) {
@@ -244,11 +320,13 @@ export function registerPurchaseTopicIngestion(
try {
const status = await repository.save(record, options.llmFallback)
const acknowledgement = buildPurchaseAcknowledgement(status)
if (status === 'created') {
if (status.status === 'created') {
options.logger?.info(
{
event: 'purchase.ingested',
processingStatus: status.processingStatus,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
@@ -258,6 +336,10 @@ export function registerPurchaseTopicIngestion(
'Purchase topic message ingested'
)
}
if (acknowledgement) {
await replyToPurchaseMessage(ctx, acknowledgement)
}
} catch (error) {
options.logger?.error(
{
@@ -308,12 +390,14 @@ export function registerConfiguredPurchaseTopicIngestion(
try {
const status = await repository.save(record, options.llmFallback)
const acknowledgement = buildPurchaseAcknowledgement(status)
if (status === 'created') {
if (status.status === 'created') {
options.logger?.info(
{
event: 'purchase.ingested',
householdId: record.householdId,
processingStatus: status.processingStatus,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
@@ -323,6 +407,10 @@ export function registerConfiguredPurchaseTopicIngestion(
'Purchase topic message ingested'
)
}
if (acknowledgement) {
await replyToPurchaseMessage(ctx, acknowledgement)
}
} catch (error) {
options.logger?.error(
{