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

88
.github/workflows/cd-aws.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: CD / AWS
on:
workflow_run:
workflows:
- CI
types:
- completed
branches:
- main
workflow_dispatch:
inputs:
stack:
description: 'Pulumi stack'
required: true
default: 'dev'
ref:
description: 'Git ref to deploy (branch, tag, or SHA)'
required: true
default: 'main'
permissions:
contents: read
id-token: write
concurrency:
group: cd-aws-${{ github.event_name == 'workflow_dispatch' && inputs.stack || github.ref_name }}
cancel-in-progress: false
jobs:
deploy:
name: Deploy AWS target
runs-on: ubuntu-latest
timeout-minutes: 45
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
env:
AWS_REGION: ${{ vars.AWS_REGION }}
PULUMI_STACK: ${{ github.event_name == 'workflow_dispatch' && inputs.stack || vars.PULUMI_STACK }}
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
BOT_API_URL: ${{ vars.BOT_API_URL }}
steps:
- name: Checkout deployment ref
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.workflow_run.head_sha }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ vars.AWS_REGION }}
- name: Setup Pulumi
uses: pulumi/actions@v6
with:
command: login
- name: Select Pulumi stack
working-directory: infra/pulumi/aws
run: pulumi stack select "$PULUMI_STACK"
- name: Preview infrastructure
run: bun run infra:aws:preview -- --stack "$PULUMI_STACK" --non-interactive
- name: Apply infrastructure
run: bun run infra:aws:up -- --stack "$PULUMI_STACK" --yes --non-interactive
- name: Resolve miniapp bucket
id: outputs
working-directory: infra/pulumi/aws
run: |
echo "miniapp_bucket=$(pulumi stack output miniAppBucketName --stack "$PULUMI_STACK")" >> "$GITHUB_OUTPUT"
echo "bot_origin_url=$(pulumi stack output botOriginUrl --stack "$PULUMI_STACK")" >> "$GITHUB_OUTPUT"
- name: Publish miniapp assets
env:
AWS_MINIAPP_BUCKET: ${{ steps.outputs.outputs.miniapp_bucket }}
BOT_API_URL: ${{ vars.BOT_API_URL || steps.outputs.outputs.bot_origin_url }}
run: bun run ops:aws:miniapp:publish

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
*.tfvars *.tfvars
*.tfvars.json *.tfvars.json
!infra/terraform/terraform.tfvars.example !infra/terraform/terraform.tfvars.example
# pulumi state
infra/pulumi/aws/.pulumi

View File

@@ -6,6 +6,7 @@ WORKDIR /app
COPY bun.lock package.json tsconfig.base.json ./ COPY bun.lock package.json tsconfig.base.json ./
COPY apps/bot/package.json apps/bot/package.json COPY apps/bot/package.json apps/bot/package.json
COPY apps/miniapp/package.json apps/miniapp/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/adapters-db/package.json packages/adapters-db/package.json
COPY packages/application/package.json packages/application/package.json COPY packages/application/package.json packages/application/package.json
COPY packages/config/package.json packages/config/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", "type": "module",
"scripts": { "scripts": {
"dev": "bun run src/index.ts", "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", "typecheck": "tsgo --project tsconfig.json --noEmit",
"test": "bun test --pass-with-no-tests", "test": "bun test --pass-with-no-tests",
"lint": "oxlint \"src\"" "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 { getLogger } from '@household/observability'
import type { InlineKeyboardMarkup } from 'grammy/types'
import { import { createBotRuntimeApp } from './app'
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
})
if (import.meta.main) { if (import.meta.main) {
const app = await createBotRuntimeApp()
const logger = getLogger('runtime')
Bun.serve({ Bun.serve({
port: runtime.port, port: app.runtime.port,
fetch: server.fetch fetch: app.fetch
}) })
logger.info( logger.info(
{ {
event: 'runtime.started', event: 'runtime.started',
port: runtime.port, mode: 'bun',
webhookPath: runtime.telegramWebhookPath port: app.runtime.port,
webhookPath: app.runtime.telegramWebhookPath
}, },
'Bot webhook server started' 'Bot webhook server started'
) )
@@ -849,10 +30,6 @@ if (import.meta.main) {
'Bot shutdown requested' 'Bot shutdown requested'
) )
for (const close of shutdownTasks) { void app.shutdown()
void close()
}
}) })
} }
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()
}

View File

@@ -6,6 +6,7 @@ WORKDIR /app
COPY bun.lock package.json tsconfig.base.json ./ COPY bun.lock package.json tsconfig.base.json ./
COPY apps/bot/package.json apps/bot/package.json COPY apps/bot/package.json apps/bot/package.json
COPY apps/miniapp/package.json apps/miniapp/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/adapters-db/package.json packages/adapters-db/package.json
COPY packages/application/package.json packages/application/package.json COPY packages/application/package.json packages/application/package.json
COPY packages/config/package.json packages/config/package.json COPY packages/config/package.json packages/config/package.json

719
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
# AWS Lambda + Pulumi Deployment Runbook
## Purpose
Deploy the bot runtime to AWS Lambda as a Bun container image, publish the miniapp to S3 website hosting, and place Cloudflare in front of both public origins.
This runbook is additive to the current GCP path. It does not replace the existing Terraform/Cloud Run deployment flow.
## Architecture
- Bot/API origin: AWS Lambda Function URL backed by `apps/bot/Dockerfile.lambda`
- Miniapp origin: S3 website hosting for `apps/miniapp/dist`
- Public edge: `api.<domain>` proxied by Cloudflare to the Lambda Function URL
- Public edge: `app.<domain>` proxied by Cloudflare to the S3 website endpoint
- Scheduler: Supabase Cron calling `https://api.<domain>/jobs/reminder/<type>`
## Prerequisites
- AWS account with permissions for ECR, Lambda, IAM, S3, and Secrets Manager
- Pulumi backend access
- Cloudflare zone access
- Supabase project with Cron enabled
- Bun `1.3.10`
- Docker
- AWS CLI
## Required Pulumi config
Set these on the target stack:
```bash
cd infra/pulumi/aws
pulumi config set publicApiHostname "api.example.com"
pulumi config set publicMiniappHostname "app.example.com"
pulumi config set miniAppAllowedOrigins '["https://app.example.com"]' --path
pulumi config set miniAppUrl "https://app.example.com"
pulumi config set --secret telegramBotToken "<token>"
pulumi config set --secret telegramWebhookSecret "<secret>"
pulumi config set --secret databaseUrl "<database-url>"
pulumi config set --secret schedulerSharedSecret "<scheduler-secret>"
pulumi config set --secret openaiApiKey "<openai-key>"
```
Optional:
```bash
pulumi config set environment "prod"
pulumi config set appName "household"
pulumi config set logLevel "info"
pulumi config set purchaseParserModel "gpt-4o-mini"
pulumi config set assistantModel "gpt-4o-mini"
pulumi config set topicProcessorModel "gpt-4o-mini"
pulumi config set memorySize "1024"
pulumi config set timeout "30"
```
## Deploy infrastructure
From the repo root:
```bash
bun run infra:aws:preview -- --stack <stack>
bun run infra:aws:up -- --stack <stack> --yes
```
Capture outputs:
```bash
cd infra/pulumi/aws
pulumi stack output botOriginUrl --stack <stack>
pulumi stack output miniAppWebsiteUrl --stack <stack>
pulumi stack output miniAppBucketName --stack <stack>
pulumi stack output cloudflareApiCnameTarget --stack <stack>
pulumi stack output cloudflareMiniappCnameTarget --stack <stack>
```
## Publish miniapp
From the repo root:
```bash
export AWS_MINIAPP_BUCKET="<bucket-name>"
export BOT_API_URL="https://api.example.com"
export AWS_REGION="<region>"
bun run ops:aws:miniapp:publish
```
## Cloudflare setup
Create proxied DNS records:
- `api` CNAME -> Pulumi output `cloudflareApiCnameTarget`
- `app` CNAME -> Pulumi output `cloudflareMiniappCnameTarget`
Recommended Cloudflare settings:
- SSL/TLS mode: `Flexible` for the S3 website origin path if you keep S3 website hosting
- Cache bypass for `api.<domain>/*`
- Cache static assets aggressively for `app.<domain>/assets/*`
- Optional WAF or rate limits for `/webhook/telegram`
- Optional WAF or rate limits for `/jobs/reminder/*`
Note: S3 website hosting is HTTP-only between Cloudflare and the bucket website endpoint. If you want stricter origin hardening later, move to S3 + CloudFront.
## Telegram webhook cutover
```bash
export TELEGRAM_WEBHOOK_URL="https://api.example.com/webhook/telegram"
export TELEGRAM_BOT_TOKEN="<token>"
export TELEGRAM_WEBHOOK_SECRET="<secret>"
bun run ops:telegram:webhook set
bun run ops:telegram:webhook info
```
## Supabase Cron jobs
Keep the existing HTTP scheduler contract and call the public API through Cloudflare.
Required endpoints:
- `POST https://api.<domain>/jobs/reminder/utilities`
- `POST https://api.<domain>/jobs/reminder/rent-warning`
- `POST https://api.<domain>/jobs/reminder/rent-due`
Required auth:
- Header `x-household-scheduler-secret: <scheduler-secret>`
Suggested schedules:
- utilities: day 4 at 09:00 `Asia/Tbilisi`
- rent-warning: day 1 at 09:00 `Asia/Tbilisi`
- rent-due: day 3 at 09:00 `Asia/Tbilisi`
## Validation
Run the existing smoke checks with the AWS public URLs:
```bash
export BOT_API_URL="https://api.example.com"
export MINI_APP_URL="https://app.example.com"
export TELEGRAM_EXPECTED_WEBHOOK_URL="${BOT_API_URL}/webhook/telegram"
bun run ops:deploy:smoke
```
Also verify:
- Cloudflare proxies both hostnames successfully
- miniapp session and dashboard endpoints succeed via `api.<domain>`
- Supabase Cron can hit each reminder endpoint with the shared secret

View File

@@ -0,0 +1,3 @@
name: household-aws
runtime: nodejs
description: AWS Lambda + S3 deployment target for the household bot monorepo

221
infra/pulumi/aws/index.ts Normal file
View File

@@ -0,0 +1,221 @@
import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
import * as pulumi from '@pulumi/pulumi'
const config = new pulumi.Config()
const awsConfig = new pulumi.Config('aws')
const appName = config.get('appName') ?? 'household'
const environment = config.get('environment') ?? pulumi.getStack()
const tags = {
Project: appName,
Environment: environment,
ManagedBy: 'Pulumi'
}
const publicApiHostname = config.require('publicApiHostname')
const publicMiniappHostname = config.require('publicMiniappHostname')
const miniAppAllowedOrigins = config.getObject<string[]>('miniAppAllowedOrigins') ?? [
`https://${publicMiniappHostname}`
]
const miniAppUrl = config.get('miniAppUrl') ?? `https://${publicMiniappHostname}`
const logLevel = config.get('logLevel') ?? 'info'
const purchaseParserModel = config.get('purchaseParserModel') ?? 'gpt-4o-mini'
const assistantModel = config.get('assistantModel') ?? 'gpt-4o-mini'
const topicProcessorModel = config.get('topicProcessorModel') ?? 'gpt-4o-mini'
const telegramBotToken = config.requireSecret('telegramBotToken')
const telegramWebhookSecret = config.requireSecret('telegramWebhookSecret')
const databaseUrl = config.getSecret('databaseUrl')
const schedulerSharedSecret = config.getSecret('schedulerSharedSecret')
const openaiApiKey = config.getSecret('openaiApiKey')
const ecrRepository = new aws.ecr.Repository(`${appName}-${environment}-bot`, {
forceDelete: true,
imageTagMutability: 'MUTABLE',
imageScanningConfiguration: {
scanOnPush: true
},
tags
})
const botImage = new awsx.ecr.Image(`${appName}-${environment}-bot-image`, {
repositoryUrl: ecrRepository.repositoryUrl,
context: '../../../',
dockerfile: '../../../apps/bot/Dockerfile.lambda',
platform: 'linux/amd64'
})
const lambdaRole = new aws.iam.Role(`${appName}-${environment}-lambda-role`, {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
Service: 'lambda.amazonaws.com'
}),
tags
})
new aws.iam.RolePolicyAttachment(`${appName}-${environment}-lambda-basic-exec`, {
role: lambdaRole.name,
policyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
})
const secretSpecs = [
{
key: 'telegramBotToken',
name: `${appName}/${environment}/telegram-bot-token`,
description: 'Telegram bot token for the household bot runtime',
value: telegramBotToken
},
{
key: 'telegramWebhookSecret',
name: `${appName}/${environment}/telegram-webhook-secret`,
description: 'Telegram webhook secret for the household bot runtime',
value: telegramWebhookSecret
},
{
key: 'databaseUrl',
name: `${appName}/${environment}/database-url`,
description: 'Database URL for the household bot runtime',
value: databaseUrl
},
{
key: 'schedulerSharedSecret',
name: `${appName}/${environment}/scheduler-shared-secret`,
description: 'Shared secret used by Supabase Cron reminder calls',
value: schedulerSharedSecret
},
{
key: 'openaiApiKey',
name: `${appName}/${environment}/openai-api-key`,
description: 'OpenAI API key for assistant and parsing features',
value: openaiApiKey
}
] as const
const secrets = Object.fromEntries(
secretSpecs.map(({ key, name, description, value }) => {
const secret = new aws.secretsmanager.Secret(`${appName}-${environment}-${key}`, {
name,
description,
recoveryWindowInDays: 0,
tags
})
if (value) {
new aws.secretsmanager.SecretVersion(`${appName}-${environment}-${key}-version`, {
secretId: secret.id,
secretString: value
})
}
return [key, secret]
})
) as Record<(typeof secretSpecs)[number]['key'], aws.secretsmanager.Secret>
const bucket = new aws.s3.BucketV2(`${appName}-${environment}-miniapp`, {
bucket: `${appName}-${environment}-miniapp`,
tags
})
new aws.s3.BucketOwnershipControls(`${appName}-${environment}-miniapp-ownership`, {
bucket: bucket.id,
rule: {
objectOwnership: 'BucketOwnerPreferred'
}
})
new aws.s3.BucketPublicAccessBlock(`${appName}-${environment}-miniapp-public-access`, {
bucket: bucket.id,
blockPublicAcls: false,
blockPublicPolicy: false,
ignorePublicAcls: false,
restrictPublicBuckets: false
})
new aws.s3.BucketWebsiteConfigurationV2(`${appName}-${environment}-miniapp-website`, {
bucket: bucket.id,
indexDocument: {
suffix: 'index.html'
},
errorDocument: {
key: 'index.html'
}
})
new aws.s3.BucketPolicy(`${appName}-${environment}-miniapp-policy`, {
bucket: bucket.id,
policy: bucket.arn.apply((bucketArn) =>
JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Sid: 'AllowPublicRead',
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: `${bucketArn}/*`
}
]
})
)
})
const lambda = new aws.lambda.Function(`${appName}-${environment}-bot`, {
packageType: 'Image',
imageUri: botImage.imageUri,
role: lambdaRole.arn,
memorySize: config.getNumber('memorySize') ?? 1024,
timeout: config.getNumber('timeout') ?? 30,
architectures: ['x86_64'],
environment: {
variables: {
NODE_ENV: 'production',
LOG_LEVEL: logLevel,
TELEGRAM_BOT_TOKEN: telegramBotToken,
TELEGRAM_WEBHOOK_SECRET: telegramWebhookSecret,
TELEGRAM_WEBHOOK_PATH: config.get('telegramWebhookPath') ?? '/webhook/telegram',
DATABASE_URL: databaseUrl ?? '',
SCHEDULER_SHARED_SECRET: schedulerSharedSecret ?? '',
OPENAI_API_KEY: openaiApiKey ?? '',
MINI_APP_URL: miniAppUrl,
MINI_APP_ALLOWED_ORIGINS: miniAppAllowedOrigins.join(','),
PURCHASE_PARSER_MODEL: purchaseParserModel,
ASSISTANT_MODEL: assistantModel,
TOPIC_PROCESSOR_MODEL: topicProcessorModel
}
},
tags
})
const functionUrl = new aws.lambda.FunctionUrl(`${appName}-${environment}-bot-url`, {
functionName: lambda.name,
authorizationType: 'NONE',
cors: {
allowCredentials: false,
allowHeaders: ['*'],
allowMethods: ['*'],
allowOrigins: miniAppAllowedOrigins,
exposeHeaders: ['*'],
maxAge: 300
}
})
const region = awsConfig.get('region') || aws.getRegionOutput().name
export const botOriginUrl = functionUrl.functionUrl
export const miniAppBucketName = bucket.bucket
export const miniAppWebsiteUrl = pulumi.interpolate`http://${bucket.websiteEndpoint}`
export const cloudflareApiCnameTarget = pulumi
.output(functionUrl.functionUrl)
.apply((url) => new URL(url).hostname)
export const cloudflareMiniappCnameTarget = bucket.websiteEndpoint
export const publicApiHostnameOutput = publicApiHostname
export const publicMiniappHostnameOutput = publicMiniappHostname
export const awsRegion = region
export const ecrRepositoryUrl = ecrRepository.repositoryUrl
export const secretIds = {
telegramBotToken: secrets.telegramBotToken.id,
telegramWebhookSecret: secrets.telegramWebhookSecret.id,
databaseUrl: secrets.databaseUrl.id,
schedulerSharedSecret: secrets.schedulerSharedSecret.id,
openaiApiKey: secrets.openaiApiKey.id
}

View File

@@ -0,0 +1,21 @@
{
"name": "@household/infra-aws",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --project tsconfig.json --noEmit",
"typecheck": "tsc --project tsconfig.json --noEmit",
"test": "echo 'No tests for Pulumi program'",
"lint": "oxlint .",
"preview": "pulumi preview",
"up": "pulumi up"
},
"dependencies": {
"@pulumi/aws": "^7.9.1",
"@pulumi/awsx": "^3.3.0",
"@pulumi/pulumi": "^3.194.0"
},
"devDependencies": {
"@types/node": "^24.5.2"
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"noEmit": true
},
"include": ["./**/*.ts"]
}

View File

@@ -5,7 +5,8 @@
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*", "packages/*",
"scripts" "scripts",
"infra/pulumi/*"
], ],
"scripts": { "scripts": {
"build": "bun run --filter '*' build", "build": "bun run --filter '*' build",
@@ -36,11 +37,17 @@
"dev:bot": "bun run --filter @household/bot dev", "dev:bot": "bun run --filter @household/bot dev",
"dev:miniapp": "bun run --filter @household/miniapp dev", "dev:miniapp": "bun run --filter @household/miniapp dev",
"docker:build:bot": "docker build -f apps/bot/Dockerfile -t household-bot:local .", "docker:build:bot": "docker build -f apps/bot/Dockerfile -t household-bot:local .",
"docker:build:bot:lambda": "docker build -f apps/bot/Dockerfile.lambda -t household-bot-lambda:local .",
"docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .", "docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .",
"docker:build": "bun run docker:build:bot && bun run docker:build:miniapp", "docker:build": "bun run docker:build:bot && bun run docker:build:miniapp",
"docker:push:bot:lambda": "bun run scripts/ops/push-bot-aws-lambda-image.ts",
"docker:smoke": "docker compose up --build", "docker:smoke": "docker compose up --build",
"infra:aws:preview": "bun run --cwd infra/pulumi/aws preview",
"infra:aws:up": "bun run --cwd infra/pulumi/aws up",
"infra:aws:typecheck": "bun run --cwd infra/pulumi/aws typecheck",
"test:e2e": "bun run scripts/e2e/billing-flow.ts", "test:e2e": "bun run scripts/e2e/billing-flow.ts",
"ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts", "ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts",
"ops:aws:miniapp:publish": "bun run scripts/ops/publish-miniapp-aws.ts",
"ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts", "ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts",
"ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts", "ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts",
"ops:reminder": "bun run scripts/ops/trigger-reminder.ts" "ops:reminder": "bun run scripts/ops/trigger-reminder.ts"

View File

@@ -0,0 +1,42 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const bucket = process.env.AWS_MINIAPP_BUCKET
const botApiUrl = process.env.BOT_API_URL
const awsRegion = process.env.AWS_REGION
const dryRun = process.env.AWS_PUBLISH_DRY_RUN === 'true'
if (!bucket) {
throw new Error('AWS_MINIAPP_BUCKET environment variable is required')
}
if (!botApiUrl) {
throw new Error('BOT_API_URL environment variable is required')
}
await Bun.$`bun run --filter @household/miniapp build`
const distDir = join(process.cwd(), 'apps/miniapp/dist')
const templatePath = join(process.cwd(), 'apps/miniapp/config.template.js')
const stagingDir = await mkdtemp(join(tmpdir(), 'household-miniapp-'))
try {
await Bun.$`cp -R ${distDir}/. ${stagingDir}`
const template = await Bun.file(templatePath).text()
const configScript = template.replace('${BOT_API_URL}', botApiUrl)
await Bun.write(join(stagingDir, 'config.js'), configScript)
const regionArgs = awsRegion ? ['--region', awsRegion] : []
const syncArgs = dryRun ? ['--dryrun'] : []
await Bun.$`aws s3 sync ${stagingDir} s3://${bucket} --delete --exclude index.html --exclude config.js --cache-control public,max-age=31536000,immutable ${regionArgs} ${syncArgs}`
await Bun.$`aws s3 cp ${join(stagingDir, 'index.html')} s3://${bucket}/index.html --cache-control no-cache ${regionArgs} ${syncArgs}`
await Bun.$`aws s3 cp ${join(stagingDir, 'config.js')} s3://${bucket}/config.js --cache-control no-cache ${regionArgs} ${syncArgs}`
} finally {
await rm(stagingDir, {
recursive: true,
force: true
})
}

View File

@@ -0,0 +1,38 @@
const repositoryUrl = process.env.AWS_ECR_REPOSITORY_URL
const imageTag = process.env.AWS_ECR_IMAGE_TAG ?? 'latest'
const awsRegion = process.env.AWS_REGION
if (!repositoryUrl) {
throw new Error('AWS_ECR_REPOSITORY_URL environment variable is required')
}
if (!awsRegion) {
throw new Error('AWS_REGION environment variable is required')
}
const imageRef = `${repositoryUrl}:${imageTag}`
const passwordProcess = Bun.spawnSync(['aws', 'ecr', 'get-login-password', '--region', awsRegion], {
stdout: 'pipe',
stderr: 'inherit'
})
if (passwordProcess.exitCode !== 0) {
throw new Error('Failed to obtain an ECR login password')
}
const loginProcess = Bun.spawnSync(
['docker', 'login', '--username', 'AWS', '--password-stdin', repositoryUrl.split('/')[0]!],
{
stdin: passwordProcess.stdout,
stdout: 'inherit',
stderr: 'inherit'
}
)
if (loginProcess.exitCode !== 0) {
throw new Error('Failed to login to ECR')
}
await Bun.$`docker build -f apps/bot/Dockerfile.lambda -t ${imageRef} .`
await Bun.$`docker push ${imageRef}`