feat(infra): add aws lambda pulumi deployment target

This commit is contained in:
2026-03-23 13:56:15 +04:00
parent 2688d66f33
commit ee8c53d89b
20 changed files with 2492 additions and 861 deletions

View File

@@ -6,6 +6,7 @@ WORKDIR /app
COPY bun.lock package.json tsconfig.base.json ./
COPY apps/bot/package.json apps/bot/package.json
COPY apps/miniapp/package.json apps/miniapp/package.json
COPY infra/pulumi/aws/package.json infra/pulumi/aws/package.json
COPY packages/adapters-db/package.json packages/adapters-db/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/config/package.json packages/config/package.json

View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
FROM oven/bun:1.3.10 AS deps
WORKDIR /app
COPY bun.lock package.json tsconfig.base.json ./
COPY apps/bot/package.json apps/bot/package.json
COPY apps/miniapp/package.json apps/miniapp/package.json
COPY infra/pulumi/aws/package.json infra/pulumi/aws/package.json
COPY packages/adapters-db/package.json packages/adapters-db/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/contracts/package.json packages/contracts/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY packages/observability/package.json packages/observability/package.json
COPY packages/ports/package.json packages/ports/package.json
COPY scripts/package.json scripts/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
WORKDIR /app
COPY apps ./apps
COPY packages ./packages
RUN bun run --filter @household/bot build
FROM oven/bun:1.3.10 AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
CMD ["bun", "apps/bot/dist/lambda.js"]

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"build": "bun build src/index.ts src/lambda.ts --outdir dist --target bun",
"typecheck": "tsgo --project tsconfig.json --noEmit",
"test": "bun test --pass-with-no-tests",
"lint": "oxlint \"src\""

843
apps/bot/src/app.ts Normal file
View File

@@ -0,0 +1,843 @@
import { webhookCallback } from 'grammy'
import type { InlineKeyboardMarkup } from 'grammy/types'
import {
createAnonymousFeedbackService,
createFinanceCommandService,
createHouseholdAdminService,
createHouseholdOnboardingService,
createHouseholdSetupService,
createLocalePreferenceService,
createMiniAppAdminService,
createPaymentConfirmationService,
createReminderJobService
} from '@household/application'
import {
createDbAnonymousFeedbackRepository,
createDbFinanceRepository,
createDbHouseholdConfigurationRepository,
createDbProcessedBotMessageRepository,
createDbReminderDispatchRepository,
createDbTelegramPendingActionRepository,
createDbTopicMessageHistoryRepository
} from '@household/adapters-db'
import { configureLogger, getLogger } from '@household/observability'
import { registerAnonymousFeedback } from './anonymous-feedback'
import {
createInMemoryAssistantConversationMemoryStore,
createInMemoryAssistantRateLimiter,
createInMemoryAssistantUsageTracker,
registerDmAssistant
} from './dm-assistant'
import { createFinanceCommandsService } from './finance-commands'
import { createTelegramBot } from './bot'
import { getBotRuntimeConfig, type BotRuntimeConfig } from './config'
import { registerHouseholdSetupCommands } from './household-setup'
import { HouseholdContextCache } from './household-context-cache'
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
import {
createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppRejectMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
createMiniAppUpdateMemberDisplayNameHandler,
createMiniAppUpdateMemberRentWeightHandler,
createMiniAppUpdateMemberStatusHandler,
createMiniAppUpdateOwnDisplayNameHandler,
createMiniAppUpdateSettingsHandler,
createMiniAppUpsertUtilityCategoryHandler
} from './miniapp-admin'
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
import {
createMiniAppAddPaymentHandler,
createMiniAppAddPurchaseHandler,
createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler,
createMiniAppCloseCycleHandler,
createMiniAppDeletePaymentHandler,
createMiniAppDeletePurchaseHandler,
createMiniAppDeleteUtilityBillHandler,
createMiniAppOpenCycleHandler,
createMiniAppRentUpdateHandler,
createMiniAppSubmitUtilityBillHandler,
createMiniAppUpdatePaymentHandler,
createMiniAppUpdatePurchaseHandler,
createMiniAppUpdateUtilityBillHandler
} from './miniapp-billing'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
import { createOpenAiChatAssistant } from './openai-chat-assistant'
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
import {
createPurchaseMessageRepository,
registerConfiguredPurchaseTopicIngestion
} from './purchase-topic-ingestion'
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { registerReminderTopicUtilities } from './reminder-topic-utilities'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server'
import { createTopicProcessor } from './topic-processor'
export interface BotRuntimeApp {
readonly fetch: (request: Request) => Promise<Response>
readonly shutdown: () => Promise<void>
readonly runtime: BotRuntimeConfig
}
export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
const runtime = getBotRuntimeConfig()
configureLogger({
level: runtime.logLevel,
service: '@household/bot'
})
const logger = getLogger('runtime')
const shutdownTasks: Array<() => Promise<void>> = []
const householdConfigurationRepositoryClient = runtime.databaseUrl
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
: null
const bot = createTelegramBot(
runtime.telegramBotToken,
getLogger('telegram'),
householdConfigurationRepositoryClient?.repository
)
bot.botInfo = await bot.api.getMe()
const webhookHandler = webhookCallback(bot, 'std/http', {
onTimeout: 'return'
})
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
const paymentConfirmationServices = new Map<
string,
ReturnType<typeof createPaymentConfirmationService>
>()
const exchangeRateProvider = createNbgExchangeRateProvider({
logger: getLogger('fx')
})
const householdOnboardingService = householdConfigurationRepositoryClient
? createHouseholdOnboardingService({
repository: householdConfigurationRepositoryClient.repository
})
: null
const miniAppAdminService = householdConfigurationRepositoryClient
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
: null
const localePreferenceService = householdConfigurationRepositoryClient
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
: null
const telegramPendingActionRepositoryClient = runtime.databaseUrl
? createDbTelegramPendingActionRepository(runtime.databaseUrl)
: null
const processedBotMessageRepositoryClient =
runtime.databaseUrl && runtime.assistantEnabled
? createDbProcessedBotMessageRepository(runtime.databaseUrl)
: null
const purchaseRepositoryClient = runtime.databaseUrl
? createPurchaseMessageRepository(runtime.databaseUrl)
: null
const topicMessageHistoryRepositoryClient = runtime.databaseUrl
? createDbTopicMessageHistoryRepository(runtime.databaseUrl)
: null
const purchaseInterpreter = createOpenAiPurchaseInterpreter(
runtime.openaiApiKey,
runtime.purchaseParserModel
)
const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore(
runtime.assistantMemoryMaxTurns
)
const assistantRateLimiter = createInMemoryAssistantRateLimiter({
burstLimit: runtime.assistantRateLimitBurst,
burstWindowMs: runtime.assistantRateLimitBurstWindowMs,
rollingLimit: runtime.assistantRateLimitRolling,
rollingWindowMs: runtime.assistantRateLimitRollingWindowMs
})
const assistantUsageTracker = createInMemoryAssistantUsageTracker()
const conversationalAssistant = createOpenAiChatAssistant(
runtime.openaiApiKey,
runtime.assistantModel,
runtime.assistantTimeoutMs
)
const topicProcessor = createTopicProcessor(
runtime.openaiApiKey,
runtime.topicProcessorModel,
runtime.topicProcessorTimeoutMs,
getLogger('topic-processor')
)
const householdContextCache = new HouseholdContextCache()
const anonymousFeedbackRepositoryClients = new Map<
string,
ReturnType<typeof createDbAnonymousFeedbackRepository>
>()
const anonymousFeedbackServices = new Map<
string,
ReturnType<typeof createAnonymousFeedbackService>
>()
function financeServiceForHousehold(householdId: string) {
const existing = financeServices.get(householdId)
if (existing) {
return existing
}
const repositoryClient = financeRepositoryForHousehold(householdId)
const service = createFinanceCommandService({
householdId,
repository: repositoryClient.repository,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
exchangeRateProvider
})
financeServices.set(householdId, service)
return service
}
function financeRepositoryForHousehold(householdId: string) {
const existing = financeRepositoryClients.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
return repositoryClient
}
function paymentConfirmationServiceForHousehold(householdId: string) {
const existing = paymentConfirmationServices.get(householdId)
if (existing) {
return existing
}
const service = createPaymentConfirmationService({
householdId,
financeService: financeServiceForHousehold(householdId),
repository: financeRepositoryForHousehold(householdId).repository,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
exchangeRateProvider
})
paymentConfirmationServices.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 (telegramPendingActionRepositoryClient) {
shutdownTasks.push(telegramPendingActionRepositoryClient.close)
}
if (processedBotMessageRepositoryClient) {
shutdownTasks.push(processedBotMessageRepositoryClient.close)
}
if (purchaseRepositoryClient) {
shutdownTasks.push(purchaseRepositoryClient.close)
}
if (topicMessageHistoryRepositoryClient) {
shutdownTasks.push(topicMessageHistoryRepositoryClient.close)
}
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
registerConfiguredPurchaseTopicIngestion(
bot,
householdConfigurationRepositoryClient.repository,
purchaseRepositoryClient.repository,
{
...(topicProcessor
? {
topicProcessor,
contextCache: householdContextCache,
memoryStore: assistantMemoryStore,
...(topicMessageHistoryRepositoryClient
? {
historyRepository: topicMessageHistoryRepositoryClient.repository
}
: {})
}
: {}),
...(purchaseInterpreter
? {
interpreter: purchaseInterpreter
}
: {}),
logger: getLogger('purchase-ingestion')
}
)
registerConfiguredPaymentTopicIngestion(
bot,
householdConfigurationRepositoryClient.repository,
telegramPendingActionRepositoryClient!.repository,
financeServiceForHousehold,
paymentConfirmationServiceForHousehold,
{
...(topicProcessor
? {
topicProcessor,
contextCache: householdContextCache,
memoryStore: assistantMemoryStore,
...(topicMessageHistoryRepositoryClient
? {
historyRepository: topicMessageHistoryRepositoryClient.repository
}
: {})
}
: {}),
logger: getLogger('payment-ingestion')
}
)
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'purchase-topic-ingestion'
},
'Purchase topic ingestion is disabled. Set DATABASE_URL to enable Telegram topic lookups.'
)
}
if (runtime.financeCommandsEnabled) {
const financeCommands = createFinanceCommandsService({
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
financeServiceForHousehold,
...(runtime.miniAppUrl
? {
miniAppUrl: runtime.miniAppUrl,
botUsername: bot.botInfo?.username
}
: {})
})
financeCommands.register(bot)
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'finance-commands'
},
'Finance commands are disabled. Set DATABASE_URL to enable household lookups.'
)
}
if (householdConfigurationRepositoryClient) {
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(
householdConfigurationRepositoryClient.repository
),
householdAdminService: createHouseholdAdminService(
householdConfigurationRepositoryClient.repository
),
householdOnboardingService: householdOnboardingService!,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
...(telegramPendingActionRepositoryClient
? {
promptRepository: telegramPendingActionRepositoryClient.repository
}
: {}),
...(runtime.miniAppUrl
? {
miniAppUrl: runtime.miniAppUrl
}
: {}),
logger: getLogger('household-setup')
})
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'household-setup'
},
'Household setup commands are disabled. Set DATABASE_URL to enable.'
)
}
const reminderJobs = runtime.reminderJobsEnabled
? (() => {
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
const reminderService = createReminderJobService(reminderRepositoryClient.repository)
shutdownTasks.push(reminderRepositoryClient.close)
return createReminderJobsHandler({
listReminderTargets: () =>
householdConfigurationRepositoryClient!.repository.listReminderTargets(),
ensureBillingCycle: async ({ householdId, at }) => {
await financeServiceForHousehold(householdId).ensureExpectedCycle(at)
},
releaseReminderDispatch: (input) =>
reminderRepositoryClient.repository.releaseReminderDispatch(input),
sendReminderMessage: async (target, content) => {
const threadId =
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) {
throw new Error(
`Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}`
)
}
await bot.api.sendMessage(target.telegramChatId, content.text, {
...(threadId
? {
message_thread_id: threadId
}
: {}),
...(content.replyMarkup
? {
reply_markup: content.replyMarkup as InlineKeyboardMarkup
}
: {})
})
},
reminderService,
...(runtime.miniAppUrl
? {
miniAppUrl: runtime.miniAppUrl
}
: {}),
...(bot.botInfo?.username
? {
botUsername: bot.botInfo.username
}
: {}),
logger: getLogger('scheduler')
})
})()
: null
if (!runtime.reminderJobsEnabled) {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'reminder-jobs'
},
'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
)
}
if (
runtime.anonymousFeedbackEnabled &&
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient
) {
registerAnonymousFeedback({
bot,
anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
logger: getLogger('anonymous-feedback')
})
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'anonymous-feedback'
},
'Anonymous feedback is disabled. Set DATABASE_URL to enable household and topic lookups.'
)
}
if (
runtime.assistantEnabled &&
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient
) {
if (processedBotMessageRepositoryClient) {
registerDmAssistant({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
messageProcessingRepository: processedBotMessageRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
financeServiceForHousehold,
memoryStore: assistantMemoryStore,
rateLimiter: assistantRateLimiter,
usageTracker: assistantUsageTracker,
...(purchaseRepositoryClient
? {
purchaseRepository: purchaseRepositoryClient.repository
}
: {}),
...(topicMessageHistoryRepositoryClient
? {
topicMessageHistoryRepository: topicMessageHistoryRepositoryClient.repository
}
: {}),
...(purchaseInterpreter
? {
purchaseInterpreter
}
: {}),
...(conversationalAssistant
? {
assistant: conversationalAssistant
}
: {}),
logger: getLogger('dm-assistant')
})
} else {
registerDmAssistant({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
financeServiceForHousehold,
memoryStore: assistantMemoryStore,
rateLimiter: assistantRateLimiter,
usageTracker: assistantUsageTracker,
...(purchaseRepositoryClient
? {
purchaseRepository: purchaseRepositoryClient.repository
}
: {}),
...(topicMessageHistoryRepositoryClient
? {
topicMessageHistoryRepository: topicMessageHistoryRepositoryClient.repository
}
: {}),
...(purchaseInterpreter
? {
purchaseInterpreter
}
: {}),
...(conversationalAssistant
? {
assistant: conversationalAssistant
}
: {}),
logger: getLogger('dm-assistant')
})
}
}
if (householdConfigurationRepositoryClient && telegramPendingActionRepositoryClient) {
registerReminderTopicUtilities({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
financeServiceForHousehold,
logger: getLogger('reminder-utilities')
})
}
const server = createBotWebhookServer({
webhookPath: runtime.telegramWebhookPath,
webhookSecret: runtime.telegramWebhookSecret,
webhookHandler,
miniAppAuth: householdOnboardingService
? createMiniAppAuthHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
logger: getLogger('miniapp-auth')
})
: undefined,
miniAppJoin: householdOnboardingService
? createMiniAppJoinHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
logger: getLogger('miniapp-auth')
})
: undefined,
miniAppDashboard: householdOnboardingService
? createMiniAppDashboardHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
financeServiceForHousehold,
onboardingService: householdOnboardingService,
logger: getLogger('miniapp-dashboard')
})
: undefined,
miniAppPendingMembers: householdOnboardingService
? createMiniAppPendingMembersHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppApproveMember: householdOnboardingService
? createMiniAppApproveMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppRejectMember: householdOnboardingService
? createMiniAppRejectMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppSettings: householdOnboardingService
? createMiniAppSettingsHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
assistantUsageTracker,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateSettings: householdOnboardingService
? createMiniAppUpdateSettingsHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpsertUtilityCategory: householdOnboardingService
? createMiniAppUpsertUtilityCategoryHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppPromoteMember: householdOnboardingService
? createMiniAppPromoteMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateOwnDisplayName: householdOnboardingService
? createMiniAppUpdateOwnDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberDisplayName: householdOnboardingService
? createMiniAppUpdateMemberDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberRentWeight: householdOnboardingService
? createMiniAppUpdateMemberRentWeightHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberStatus: householdOnboardingService
? createMiniAppUpdateMemberStatusHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberAbsencePolicy: householdOnboardingService
? createMiniAppUpdateMemberAbsencePolicyHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppBillingCycle: householdOnboardingService
? createMiniAppBillingCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppOpenCycle: householdOnboardingService
? createMiniAppOpenCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppCloseCycle: householdOnboardingService
? createMiniAppCloseCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppRentUpdate: householdOnboardingService
? createMiniAppRentUpdateHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppAddUtilityBill: householdOnboardingService
? createMiniAppAddUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppSubmitUtilityBill: householdOnboardingService
? createMiniAppSubmitUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdateUtilityBill: householdOnboardingService
? createMiniAppUpdateUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeleteUtilityBill: householdOnboardingService
? createMiniAppDeleteUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppAddPurchase: householdOnboardingService
? createMiniAppAddPurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdatePurchase: householdOnboardingService
? createMiniAppUpdatePurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeletePurchase: householdOnboardingService
? createMiniAppDeletePurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppAddPayment: householdOnboardingService
? createMiniAppAddPaymentHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdatePayment: householdOnboardingService
? createMiniAppUpdatePaymentHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeletePayment: householdOnboardingService
? createMiniAppDeletePaymentHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppLocalePreference: householdOnboardingService
? createMiniAppLocalePreferenceHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
localePreferenceService: localePreferenceService!,
logger: getLogger('miniapp-admin')
})
: undefined,
scheduler:
reminderJobs && runtime.schedulerSharedSecret
? {
authorize: createSchedulerRequestAuthorizer({
sharedSecret: runtime.schedulerSharedSecret,
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: reminderJobs
? {
authorize: createSchedulerRequestAuthorizer({
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: undefined
})
return {
fetch: server.fetch,
runtime,
shutdown: async () => {
await Promise.allSettled(shutdownTasks.map((close) => close()))
}
}
}

View File

@@ -1,841 +1,22 @@
import { webhookCallback } from 'grammy'
import type { InlineKeyboardMarkup } from 'grammy/types'
import { getLogger } from '@household/observability'
import {
createAnonymousFeedbackService,
createHouseholdAdminService,
createFinanceCommandService,
createHouseholdOnboardingService,
createLocalePreferenceService,
createMiniAppAdminService,
createHouseholdSetupService,
createReminderJobService,
createPaymentConfirmationService
} from '@household/application'
import {
createDbAnonymousFeedbackRepository,
createDbFinanceRepository,
createDbHouseholdConfigurationRepository,
createDbProcessedBotMessageRepository,
createDbReminderDispatchRepository,
createDbTelegramPendingActionRepository,
createDbTopicMessageHistoryRepository
} from '@household/adapters-db'
import { configureLogger, getLogger } from '@household/observability'
import { registerAnonymousFeedback } from './anonymous-feedback'
import {
createInMemoryAssistantConversationMemoryStore,
createInMemoryAssistantRateLimiter,
createInMemoryAssistantUsageTracker,
registerDmAssistant
} from './dm-assistant'
import { createFinanceCommandsService } from './finance-commands'
import { createTelegramBot } from './bot'
import { getBotRuntimeConfig } from './config'
import { registerHouseholdSetupCommands } from './household-setup'
import { createOpenAiChatAssistant } from './openai-chat-assistant'
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
import { createTopicProcessor } from './topic-processor'
import { HouseholdContextCache } from './household-context-cache'
import {
createPurchaseMessageRepository,
registerConfiguredPurchaseTopicIngestion
} from './purchase-topic-ingestion'
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { registerReminderTopicUtilities } from './reminder-topic-utilities'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server'
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
import {
createMiniAppApproveMemberHandler,
createMiniAppRejectMemberHandler,
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateMemberDisplayNameHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
createMiniAppUpdateOwnDisplayNameHandler,
createMiniAppUpdateMemberStatusHandler,
createMiniAppUpdateMemberRentWeightHandler,
createMiniAppUpdateSettingsHandler,
createMiniAppUpsertUtilityCategoryHandler
} from './miniapp-admin'
import {
createMiniAppAddPaymentHandler,
createMiniAppAddPurchaseHandler,
createMiniAppAddUtilityBillHandler,
createMiniAppBillingCycleHandler,
createMiniAppCloseCycleHandler,
createMiniAppDeletePaymentHandler,
createMiniAppDeletePurchaseHandler,
createMiniAppDeleteUtilityBillHandler,
createMiniAppOpenCycleHandler,
createMiniAppRentUpdateHandler,
createMiniAppSubmitUtilityBillHandler,
createMiniAppUpdatePaymentHandler,
createMiniAppUpdatePurchaseHandler,
createMiniAppUpdateUtilityBillHandler
} from './miniapp-billing'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
const runtime = getBotRuntimeConfig()
configureLogger({
level: runtime.logLevel,
service: '@household/bot'
})
const logger = getLogger('runtime')
const shutdownTasks: Array<() => Promise<void>> = []
const householdConfigurationRepositoryClient = runtime.databaseUrl
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
: null
const bot = createTelegramBot(
runtime.telegramBotToken,
getLogger('telegram'),
householdConfigurationRepositoryClient?.repository
)
bot.botInfo = await bot.api.getMe()
const webhookHandler = webhookCallback(bot, 'std/http', {
onTimeout: 'return'
})
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
const paymentConfirmationServices = new Map<
string,
ReturnType<typeof createPaymentConfirmationService>
>()
const exchangeRateProvider = createNbgExchangeRateProvider({
logger: getLogger('fx')
})
const householdOnboardingService = householdConfigurationRepositoryClient
? createHouseholdOnboardingService({
repository: householdConfigurationRepositoryClient.repository
})
: null
const miniAppAdminService = householdConfigurationRepositoryClient
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
: null
const localePreferenceService = householdConfigurationRepositoryClient
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
: null
const telegramPendingActionRepositoryClient = runtime.databaseUrl
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
: null
const processedBotMessageRepositoryClient =
runtime.databaseUrl && runtime.assistantEnabled
? createDbProcessedBotMessageRepository(runtime.databaseUrl!)
: null
const purchaseRepositoryClient = runtime.databaseUrl
? createPurchaseMessageRepository(runtime.databaseUrl!)
: null
const topicMessageHistoryRepositoryClient = runtime.databaseUrl
? createDbTopicMessageHistoryRepository(runtime.databaseUrl!)
: null
const purchaseInterpreter = createOpenAiPurchaseInterpreter(
runtime.openaiApiKey,
runtime.purchaseParserModel
)
const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore(
runtime.assistantMemoryMaxTurns
)
const assistantRateLimiter = createInMemoryAssistantRateLimiter({
burstLimit: runtime.assistantRateLimitBurst,
burstWindowMs: runtime.assistantRateLimitBurstWindowMs,
rollingLimit: runtime.assistantRateLimitRolling,
rollingWindowMs: runtime.assistantRateLimitRollingWindowMs
})
const assistantUsageTracker = createInMemoryAssistantUsageTracker()
const conversationalAssistant = createOpenAiChatAssistant(
runtime.openaiApiKey,
runtime.assistantModel,
runtime.assistantTimeoutMs
)
const topicProcessor = createTopicProcessor(
runtime.openaiApiKey,
runtime.topicProcessorModel,
runtime.topicProcessorTimeoutMs,
getLogger('topic-processor')
)
const householdContextCache = new HouseholdContextCache()
const anonymousFeedbackRepositoryClients = new Map<
string,
ReturnType<typeof createDbAnonymousFeedbackRepository>
>()
const anonymousFeedbackServices = new Map<
string,
ReturnType<typeof createAnonymousFeedbackService>
>()
function financeServiceForHousehold(householdId: string) {
const existing = financeServices.get(householdId)
if (existing) {
return existing
}
const repositoryClient = financeRepositoryForHousehold(householdId)
const service = createFinanceCommandService({
householdId,
repository: repositoryClient.repository,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
exchangeRateProvider
})
financeServices.set(householdId, service)
return service
}
function financeRepositoryForHousehold(householdId: string) {
const existing = financeRepositoryClients.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
return repositoryClient
}
function paymentConfirmationServiceForHousehold(householdId: string) {
const existing = paymentConfirmationServices.get(householdId)
if (existing) {
return existing
}
const service = createPaymentConfirmationService({
householdId,
financeService: financeServiceForHousehold(householdId),
repository: financeRepositoryForHousehold(householdId).repository,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
exchangeRateProvider
})
paymentConfirmationServices.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 (telegramPendingActionRepositoryClient) {
shutdownTasks.push(telegramPendingActionRepositoryClient.close)
}
if (processedBotMessageRepositoryClient) {
shutdownTasks.push(processedBotMessageRepositoryClient.close)
}
if (purchaseRepositoryClient) {
shutdownTasks.push(purchaseRepositoryClient.close)
}
if (topicMessageHistoryRepositoryClient) {
shutdownTasks.push(topicMessageHistoryRepositoryClient.close)
}
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
registerConfiguredPurchaseTopicIngestion(
bot,
householdConfigurationRepositoryClient.repository,
purchaseRepositoryClient.repository,
{
...(topicProcessor
? {
topicProcessor,
contextCache: householdContextCache,
memoryStore: assistantMemoryStore,
...(topicMessageHistoryRepositoryClient
? {
historyRepository: topicMessageHistoryRepositoryClient.repository
}
: {})
}
: {}),
...(purchaseInterpreter
? {
interpreter: purchaseInterpreter
}
: {}),
logger: getLogger('purchase-ingestion')
}
)
registerConfiguredPaymentTopicIngestion(
bot,
householdConfigurationRepositoryClient.repository,
telegramPendingActionRepositoryClient!.repository,
financeServiceForHousehold,
paymentConfirmationServiceForHousehold,
{
...(topicProcessor
? {
topicProcessor,
contextCache: householdContextCache,
memoryStore: assistantMemoryStore,
...(topicMessageHistoryRepositoryClient
? {
historyRepository: topicMessageHistoryRepositoryClient.repository
}
: {})
}
: {}),
logger: getLogger('payment-ingestion')
}
)
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'purchase-topic-ingestion'
},
'Purchase topic ingestion is disabled. Set DATABASE_URL to enable Telegram topic lookups.'
)
}
if (runtime.financeCommandsEnabled) {
const financeCommands = createFinanceCommandsService({
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
financeServiceForHousehold,
...(runtime.miniAppUrl
? {
miniAppUrl: runtime.miniAppUrl,
botUsername: bot.botInfo?.username
}
: {})
})
financeCommands.register(bot)
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'finance-commands'
},
'Finance commands are disabled. Set DATABASE_URL to enable household lookups.'
)
}
if (householdConfigurationRepositoryClient) {
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(
householdConfigurationRepositoryClient.repository
),
householdAdminService: createHouseholdAdminService(
householdConfigurationRepositoryClient.repository
),
householdOnboardingService: householdOnboardingService!,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
...(telegramPendingActionRepositoryClient
? {
promptRepository: telegramPendingActionRepositoryClient.repository
}
: {}),
...(runtime.miniAppUrl
? {
miniAppUrl: runtime.miniAppUrl
}
: {}),
logger: getLogger('household-setup')
})
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'household-setup'
},
'Household setup commands are disabled. Set DATABASE_URL to enable.'
)
}
const reminderJobs = runtime.reminderJobsEnabled
? (() => {
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
const reminderService = createReminderJobService(reminderRepositoryClient.repository)
shutdownTasks.push(reminderRepositoryClient.close)
return createReminderJobsHandler({
listReminderTargets: () =>
householdConfigurationRepositoryClient!.repository.listReminderTargets(),
ensureBillingCycle: async ({ householdId, at }) => {
await financeServiceForHousehold(householdId).ensureExpectedCycle(at)
},
releaseReminderDispatch: (input) =>
reminderRepositoryClient.repository.releaseReminderDispatch(input),
sendReminderMessage: async (target, content) => {
const threadId =
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) {
throw new Error(
`Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}`
)
}
await bot.api.sendMessage(target.telegramChatId, content.text, {
...(threadId
? {
message_thread_id: threadId
}
: {}),
...(content.replyMarkup
? {
reply_markup: content.replyMarkup as InlineKeyboardMarkup
}
: {})
})
},
reminderService,
...(runtime.miniAppUrl
? {
miniAppUrl: runtime.miniAppUrl
}
: {}),
...(bot.botInfo?.username
? {
botUsername: bot.botInfo.username
}
: {}),
logger: getLogger('scheduler')
})
})()
: null
if (!runtime.reminderJobsEnabled) {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'reminder-jobs'
},
'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
)
}
if (
runtime.anonymousFeedbackEnabled &&
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient
) {
registerAnonymousFeedback({
bot,
anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
promptRepository: telegramPendingActionRepositoryClient!.repository,
logger: getLogger('anonymous-feedback')
})
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'anonymous-feedback'
},
'Anonymous feedback is disabled. Set DATABASE_URL to enable household and topic lookups.'
)
}
if (
runtime.assistantEnabled &&
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient
) {
if (processedBotMessageRepositoryClient) {
registerDmAssistant({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
messageProcessingRepository: processedBotMessageRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
financeServiceForHousehold,
memoryStore: assistantMemoryStore,
rateLimiter: assistantRateLimiter,
usageTracker: assistantUsageTracker,
...(purchaseRepositoryClient
? {
purchaseRepository: purchaseRepositoryClient.repository
}
: {}),
...(topicMessageHistoryRepositoryClient
? {
topicMessageHistoryRepository: topicMessageHistoryRepositoryClient.repository
}
: {}),
...(purchaseInterpreter
? {
purchaseInterpreter
}
: {}),
...(conversationalAssistant
? {
assistant: conversationalAssistant
}
: {}),
logger: getLogger('dm-assistant')
})
} else {
registerDmAssistant({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
financeServiceForHousehold,
memoryStore: assistantMemoryStore,
rateLimiter: assistantRateLimiter,
usageTracker: assistantUsageTracker,
...(purchaseRepositoryClient
? {
purchaseRepository: purchaseRepositoryClient.repository
}
: {}),
...(topicMessageHistoryRepositoryClient
? {
topicMessageHistoryRepository: topicMessageHistoryRepositoryClient.repository
}
: {}),
...(purchaseInterpreter
? {
purchaseInterpreter
}
: {}),
...(conversationalAssistant
? {
assistant: conversationalAssistant
}
: {}),
logger: getLogger('dm-assistant')
})
}
}
if (householdConfigurationRepositoryClient && telegramPendingActionRepositoryClient) {
registerReminderTopicUtilities({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
financeServiceForHousehold,
logger: getLogger('reminder-utilities')
})
}
const server = createBotWebhookServer({
webhookPath: runtime.telegramWebhookPath,
webhookSecret: runtime.telegramWebhookSecret,
webhookHandler,
miniAppAuth: householdOnboardingService
? createMiniAppAuthHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
logger: getLogger('miniapp-auth')
})
: undefined,
miniAppJoin: householdOnboardingService
? createMiniAppJoinHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
logger: getLogger('miniapp-auth')
})
: undefined,
miniAppDashboard: householdOnboardingService
? createMiniAppDashboardHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
financeServiceForHousehold,
onboardingService: householdOnboardingService!,
logger: getLogger('miniapp-dashboard')
})
: undefined,
miniAppPendingMembers: householdOnboardingService
? createMiniAppPendingMembersHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppApproveMember: householdOnboardingService
? createMiniAppApproveMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppRejectMember: householdOnboardingService
? createMiniAppRejectMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppSettings: householdOnboardingService
? createMiniAppSettingsHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
assistantUsageTracker,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateSettings: householdOnboardingService
? createMiniAppUpdateSettingsHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpsertUtilityCategory: householdOnboardingService
? createMiniAppUpsertUtilityCategoryHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppPromoteMember: householdOnboardingService
? createMiniAppPromoteMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateOwnDisplayName: householdOnboardingService
? createMiniAppUpdateOwnDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberDisplayName: householdOnboardingService
? createMiniAppUpdateMemberDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberRentWeight: householdOnboardingService
? createMiniAppUpdateMemberRentWeightHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberStatus: householdOnboardingService
? createMiniAppUpdateMemberStatusHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateMemberAbsencePolicy: householdOnboardingService
? createMiniAppUpdateMemberAbsencePolicyHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppBillingCycle: householdOnboardingService
? createMiniAppBillingCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppOpenCycle: householdOnboardingService
? createMiniAppOpenCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppCloseCycle: householdOnboardingService
? createMiniAppCloseCycleHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppRentUpdate: householdOnboardingService
? createMiniAppRentUpdateHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppAddUtilityBill: householdOnboardingService
? createMiniAppAddUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppSubmitUtilityBill: householdOnboardingService
? createMiniAppSubmitUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdateUtilityBill: householdOnboardingService
? createMiniAppUpdateUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeleteUtilityBill: householdOnboardingService
? createMiniAppDeleteUtilityBillHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppAddPurchase: householdOnboardingService
? createMiniAppAddPurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdatePurchase: householdOnboardingService
? createMiniAppUpdatePurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeletePurchase: householdOnboardingService
? createMiniAppDeletePurchaseHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppAddPayment: householdOnboardingService
? createMiniAppAddPaymentHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppUpdatePayment: householdOnboardingService
? createMiniAppUpdatePaymentHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppDeletePayment: householdOnboardingService
? createMiniAppDeletePaymentHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
financeServiceForHousehold,
logger: getLogger('miniapp-billing')
})
: undefined,
miniAppLocalePreference: householdOnboardingService
? createMiniAppLocalePreferenceHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
localePreferenceService: localePreferenceService!,
logger: getLogger('miniapp-admin')
})
: undefined,
scheduler:
reminderJobs && runtime.schedulerSharedSecret
? {
authorize: createSchedulerRequestAuthorizer({
sharedSecret: runtime.schedulerSharedSecret,
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: reminderJobs
? {
authorize: createSchedulerRequestAuthorizer({
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: undefined
})
import { createBotRuntimeApp } from './app'
if (import.meta.main) {
const app = await createBotRuntimeApp()
const logger = getLogger('runtime')
Bun.serve({
port: runtime.port,
fetch: server.fetch
port: app.runtime.port,
fetch: app.fetch
})
logger.info(
{
event: 'runtime.started',
port: runtime.port,
webhookPath: runtime.telegramWebhookPath
mode: 'bun',
port: app.runtime.port,
webhookPath: app.runtime.telegramWebhookPath
},
'Bot webhook server started'
)
@@ -849,10 +30,6 @@ if (import.meta.main) {
'Bot shutdown requested'
)
for (const close of shutdownTasks) {
void close()
}
void app.shutdown()
})
}
export { server }

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from 'bun:test'
import {
handleLambdaFunctionUrlEvent,
requestFromLambdaEvent,
responseToLambdaResult,
type LambdaFunctionUrlRequest
} from './lambda-adapter'
function baseEvent(overrides: Partial<LambdaFunctionUrlRequest> = {}): LambdaFunctionUrlRequest {
return {
version: '2.0',
rawPath: '/healthz',
rawQueryString: '',
headers: {
host: 'api.example.com',
'x-forwarded-proto': 'https'
},
requestContext: {
http: {
method: 'GET'
}
},
...overrides
}
}
describe('lambda adapter', () => {
test('translates a function url event into a request', async () => {
const request = requestFromLambdaEvent(
baseEvent({
rawPath: '/webhook/telegram',
rawQueryString: 'foo=bar',
headers: {
host: 'api.example.com',
'x-forwarded-proto': 'https',
'x-telegram-bot-api-secret-token': 'secret-token'
},
requestContext: {
http: {
method: 'POST'
}
},
body: JSON.stringify({ update_id: 1 })
})
)
expect(request.method).toBe('POST')
expect(request.url).toBe('https://api.example.com/webhook/telegram?foo=bar')
expect(request.headers.get('x-telegram-bot-api-secret-token')).toBe('secret-token')
expect(await request.json()).toEqual({ update_id: 1 })
})
test('translates a response into a lambda result', async () => {
const response = await responseToLambdaResult(
new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store'
}
})
)
expect(response).toEqual({
statusCode: 201,
headers: {
'cache-control': 'no-store',
'content-type': 'application/json'
},
body: JSON.stringify({ ok: true })
})
})
test('preserves health endpoint behavior through the adapter', async () => {
const response = await handleLambdaFunctionUrlEvent(
baseEvent(),
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
)
expect(response.statusCode).toBe(200)
expect(response.headers).toEqual({
'content-type': 'application/json; charset=utf-8'
})
expect(response.body).toBe(JSON.stringify({ ok: true }))
})
test('decodes base64 request bodies', async () => {
const event = baseEvent({
rawPath: '/api/miniapp/session',
requestContext: {
http: {
method: 'POST'
}
},
headers: {
host: 'api.example.com',
'x-forwarded-proto': 'https',
'content-type': 'application/json'
},
body: btoa(JSON.stringify({ hello: 'world' })),
isBase64Encoded: true
})
const request = requestFromLambdaEvent(event)
expect(await request.json()).toEqual({ hello: 'world' })
})
})

View File

@@ -0,0 +1,110 @@
export interface LambdaFunctionUrlRequest {
version: '2.0'
rawPath: string
rawQueryString?: string
headers?: Record<string, string | undefined>
cookies?: string[]
body?: string
isBase64Encoded?: boolean
requestContext: {
domainName?: string
http: {
method: string
}
}
}
export interface LambdaFunctionUrlResponse {
statusCode: number
headers?: Record<string, string>
cookies?: string[]
body: string
isBase64Encoded?: boolean
}
function normalizeHeaders(
headers: Record<string, string | undefined> | undefined
): Record<string, string> {
const normalized: Record<string, string> = {}
if (!headers) {
return normalized
}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
normalized[key] = value
}
return normalized
}
function requestUrl(event: LambdaFunctionUrlRequest): string {
const headers = normalizeHeaders(event.headers)
const host = headers.host || event.requestContext.domainName || 'lambda-url.local'
const protocol = headers['x-forwarded-proto'] || 'https'
const query = event.rawQueryString?.length ? `?${event.rawQueryString}` : ''
return `${protocol}://${host}${event.rawPath}${query}`
}
function requestBody(event: LambdaFunctionUrlRequest): string | Uint8Array | null {
if (event.body === undefined) {
return null
}
if (event.isBase64Encoded) {
return Uint8Array.from(atob(event.body), (char) => char.charCodeAt(0))
}
return event.body
}
export function requestFromLambdaEvent(event: LambdaFunctionUrlRequest): Request {
const headers = new Headers(normalizeHeaders(event.headers))
if (event.cookies?.length) {
headers.set('cookie', event.cookies.join('; '))
}
return new Request(requestUrl(event), {
method: event.requestContext.http.method,
headers,
body: requestBody(event)
})
}
export async function responseToLambdaResult(
response: Response
): Promise<LambdaFunctionUrlResponse> {
const headers: Record<string, string> = {}
const cookies: string[] = []
for (const [key, value] of response.headers.entries()) {
if (key.toLowerCase() === 'set-cookie') {
cookies.push(value)
continue
}
headers[key] = value
}
return {
statusCode: response.status,
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(cookies.length > 0 ? { cookies } : {}),
body: await response.text()
}
}
export async function handleLambdaFunctionUrlEvent(
event: LambdaFunctionUrlRequest,
handler: (request: Request) => Promise<Response>
): Promise<LambdaFunctionUrlResponse> {
const request = requestFromLambdaEvent(event)
const response = await handler(request)
return responseToLambdaResult(response)
}

90
apps/bot/src/lambda.ts Normal file
View File

@@ -0,0 +1,90 @@
import { getLogger } from '@household/observability'
import { createBotRuntimeApp } from './app'
import {
handleLambdaFunctionUrlEvent,
type LambdaFunctionUrlRequest,
type LambdaFunctionUrlResponse
} from './lambda-adapter'
const appPromise = createBotRuntimeApp()
const logger = getLogger('lambda')
export async function handler(event: LambdaFunctionUrlRequest): Promise<LambdaFunctionUrlResponse> {
const app = await appPromise
return handleLambdaFunctionUrlEvent(event, app.fetch)
}
async function postRuntimeResponse(
requestId: string,
response: LambdaFunctionUrlResponse
): Promise<void> {
const runtimeApi = process.env.AWS_LAMBDA_RUNTIME_API
if (!runtimeApi) {
throw new Error('AWS_LAMBDA_RUNTIME_API environment variable is required')
}
await fetch(`http://${runtimeApi}/2018-06-01/runtime/invocation/${requestId}/response`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(response)
})
}
async function postRuntimeError(requestId: string, error: unknown): Promise<void> {
const runtimeApi = process.env.AWS_LAMBDA_RUNTIME_API
if (!runtimeApi) {
throw new Error('AWS_LAMBDA_RUNTIME_API environment variable is required')
}
const message = error instanceof Error ? error.message : 'Unknown Lambda runtime error'
await fetch(`http://${runtimeApi}/2018-06-01/runtime/invocation/${requestId}/error`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
errorMessage: message,
errorType: error instanceof Error ? error.name : 'Error'
})
})
}
async function runtimeLoop(): Promise<void> {
const runtimeApi = process.env.AWS_LAMBDA_RUNTIME_API
if (!runtimeApi) {
throw new Error('AWS_LAMBDA_RUNTIME_API environment variable is required')
}
logger.info(
{
event: 'runtime.started',
mode: 'lambda'
},
'Bot Lambda runtime started'
)
while (true) {
const invocation = await fetch(`http://${runtimeApi}/2018-06-01/runtime/invocation/next`)
const requestId = invocation.headers.get('lambda-runtime-aws-request-id')
if (!requestId) {
throw new Error('Lambda runtime response did not include a request id')
}
try {
const event = (await invocation.json()) as LambdaFunctionUrlRequest
const response = await handler(event)
await postRuntimeResponse(requestId, response)
} catch (error) {
await postRuntimeError(requestId, error)
}
}
}
if (import.meta.main) {
void runtimeLoop()
}