mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 09:14:02 +00:00
feat(infra): add aws lambda pulumi deployment target
This commit is contained in:
88
.github/workflows/cd-aws.yml
vendored
Normal file
88
.github/workflows/cd-aws.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
apps/bot/Dockerfile.lambda
Normal file
37
apps/bot/Dockerfile.lambda
Normal 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"]
|
||||||
@@ -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
843
apps/bot/src/app.ts
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
|
|||||||
115
apps/bot/src/lambda-adapter.test.ts
Normal file
115
apps/bot/src/lambda-adapter.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
110
apps/bot/src/lambda-adapter.ts
Normal file
110
apps/bot/src/lambda-adapter.ts
Normal 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
90
apps/bot/src/lambda.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
155
docs/runbooks/aws-pulumi-deploy.md
Normal file
155
docs/runbooks/aws-pulumi-deploy.md
Normal 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
|
||||||
3
infra/pulumi/aws/Pulumi.yaml
Normal file
3
infra/pulumi/aws/Pulumi.yaml
Normal 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
221
infra/pulumi/aws/index.ts
Normal 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
|
||||||
|
}
|
||||||
21
infra/pulumi/aws/package.json
Normal file
21
infra/pulumi/aws/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
infra/pulumi/aws/tsconfig.json
Normal file
10
infra/pulumi/aws/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
42
scripts/ops/publish-miniapp-aws.ts
Normal file
42
scripts/ops/publish-miniapp-aws.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
38
scripts/ops/push-bot-aws-lambda-image.ts
Normal file
38
scripts/ops/push-bot-aws-lambda-image.ts
Normal 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}`
|
||||||
Reference in New Issue
Block a user