mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(bot): cut over multi-household member flows
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
180
apps/bot/src/household-setup.test.ts
Normal file
180
apps/bot/src/household-setup.test.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface MiniAppSessionResult {
|
||||
authorized: boolean
|
||||
member?: {
|
||||
id: string
|
||||
householdId: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user