diff --git a/.github/workflows/cd-vps.yml b/.github/workflows/cd-vps.yml index fb4451d..58c309e 100644 --- a/.github/workflows/cd-vps.yml +++ b/.github/workflows/cd-vps.yml @@ -29,13 +29,11 @@ jobs: outputs: eligible: ${{ steps.detect.outputs.eligible }} ref: ${{ steps.detect.outputs.ref }} - sha: ${{ steps.detect.outputs.sha }} steps: - id: detect run: | eligible=false ref="" - sha="" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then eligible=true @@ -47,7 +45,6 @@ jobs: echo "eligible=$eligible" >> "$GITHUB_OUTPUT" echo "ref=$ref" >> "$GITHUB_OUTPUT" - echo "sha=${ref}" >> "$GITHUB_OUTPUT" build-bot: runs-on: ubuntu-latest @@ -147,11 +144,14 @@ jobs: test -n "${{ secrets.VPS_SSH_KEY }}" - name: Prepare SSH + env: + VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }} run: | install -m 700 -d ~/.ssh printf '%s\n' '${{ secrets.VPS_SSH_KEY }}' > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" >> ~/.ssh/known_hosts + test -n "$VPS_KNOWN_HOSTS" + printf '%s\n' "$VPS_KNOWN_HOSTS" > ~/.ssh/known_hosts - name: Upload deploy bundle run: | @@ -172,14 +172,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Smoke check - env: - BOT_API_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }} - MINI_APP_URL: ${{ vars.VPS_MINIAPP_URL || 'https://household.whekin.dev' }} - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - TELEGRAM_EXPECTED_WEBHOOK_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}/webhook/telegram - run: bun run ops:deploy:smoke - - name: Set Telegram webhook env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} @@ -192,3 +184,11 @@ jobs: fi bun run ops:telegram:webhook set + + - name: Smoke check + env: + BOT_API_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }} + MINI_APP_URL: ${{ vars.VPS_MINIAPP_URL || 'https://household.whekin.dev' }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_EXPECTED_WEBHOOK_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}/webhook/telegram + run: bun run ops:deploy:smoke diff --git a/apps/bot/src/app.ts b/apps/bot/src/app.ts index c97a78a..708a309 100644 --- a/apps/bot/src/app.ts +++ b/apps/bot/src/app.ts @@ -141,7 +141,7 @@ export async function createBotRuntimeApp(): Promise { : null const scheduledDispatchScheduler = runtime.scheduledDispatch && - (runtime.scheduledDispatch.provider === 'self-hosted' || runtime.schedulerSharedSecret) + (runtime.scheduledDispatch.provider === 'aws-eventbridge' || runtime.schedulerSharedSecret) ? runtime.scheduledDispatch.provider === 'gcp-cloud-tasks' ? createGcpScheduledDispatchScheduler({ projectId: runtime.scheduledDispatch.projectId, diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 0bc8d3f..7ca47a4 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -248,8 +248,14 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu runtime.schedulerSharedSecret = schedulerSharedSecret } if (scheduledDispatch !== undefined) { - if (scheduledDispatch.provider === 'self-hosted' && schedulerSharedSecret === undefined) { - throw new Error('Self-hosted scheduled dispatch requires SCHEDULER_SHARED_SECRET') + if ( + (scheduledDispatch.provider === 'self-hosted' || + scheduledDispatch.provider === 'gcp-cloud-tasks') && + schedulerSharedSecret === undefined + ) { + throw new Error( + `${scheduledDispatch.provider} scheduled dispatch requires SCHEDULER_SHARED_SECRET` + ) } runtime.scheduledDispatch = scheduledDispatch diff --git a/apps/bot/src/scheduler-runner.ts b/apps/bot/src/scheduler-runner.ts index 00b25ba..2f8b922 100644 --- a/apps/bot/src/scheduler-runner.ts +++ b/apps/bot/src/scheduler-runner.ts @@ -20,15 +20,15 @@ function parsePositiveInteger(name: string, fallback: number): number { return parsed } -async function runOnce() { - const baseUrl = requireEnv('BOT_INTERNAL_BASE_URL').replace(/\/$/, '') - const schedulerSecret = requireEnv('SCHEDULER_SHARED_SECRET') - const dueScanLimit = parsePositiveInteger('SCHEDULER_DUE_SCAN_LIMIT', 25) - - const response = await fetch(`${baseUrl}/jobs/dispatch-due?limit=${dueScanLimit}`, { +async function runOnce(input: { + baseUrl: string + schedulerSecret: string + dueScanLimit: number +}) { + const response = await fetch(`${input.baseUrl}/jobs/dispatch-due?limit=${input.dueScanLimit}`, { method: 'POST', headers: { - 'x-household-scheduler-secret': schedulerSecret + 'x-household-scheduler-secret': input.schedulerSecret } }) @@ -51,6 +51,11 @@ async function runOnce() { async function main() { const intervalMs = parsePositiveInteger('SCHEDULER_POLL_INTERVAL_MS', 60_000) + const runConfig = { + baseUrl: requireEnv('BOT_INTERNAL_BASE_URL').replace(/\/$/, ''), + schedulerSecret: requireEnv('SCHEDULER_SHARED_SECRET'), + dueScanLimit: parsePositiveInteger('SCHEDULER_DUE_SCAN_LIMIT', 25) + } let stopping = false const stop = () => { @@ -62,7 +67,7 @@ async function main() { while (!stopping) { try { - await runOnce() + await runOnce(runConfig) } catch (error) { console.error( JSON.stringify({ diff --git a/deploy/vps/compose.yml b/deploy/vps/compose.yml index f45acdf..5d4b96e 100644 --- a/deploy/vps/compose.yml +++ b/deploy/vps/compose.yml @@ -5,7 +5,11 @@ services: env_file: - ${ENV_DIR:-/opt/household-bot/env}/bot.env healthcheck: - test: ['CMD', 'bun', '-e', "fetch('http://127.0.0.1:' + (process.env.PORT ?? '8080') + '/healthz').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"] + test: + - CMD + - bun + - -e + - "fetch('http://127.0.0.1:' + (process.env.PORT ?? '8080') + '/healthz').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))" interval: 30s timeout: 5s retries: 3 @@ -23,7 +27,9 @@ services: scheduler: image: ${BOT_IMAGE:?set BOT_IMAGE} restart: unless-stopped - command: ['bun', 'apps/bot/dist/scheduler-runner.js'] + command: + - bun + - apps/bot/dist/scheduler-runner.js env_file: - ${ENV_DIR:-/opt/household-bot/env}/bot.env environment: @@ -36,10 +42,13 @@ services: migrate: image: ${BOT_IMAGE:?set BOT_IMAGE} - profiles: ['ops'] + profiles: + - ops env_file: - ${ENV_DIR:-/opt/household-bot/env}/bot.env - command: ['bun', 'packages/db/dist/migrate.js'] + command: + - bun + - packages/db/dist/migrate.js restart: 'no' caddy: diff --git a/deploy/vps/miniapp.env.example b/deploy/vps/miniapp.env.example index 7f315e1..290a7ca 100644 --- a/deploy/vps/miniapp.env.example +++ b/deploy/vps/miniapp.env.example @@ -1 +1 @@ -BOT_API_URL=https://household-bot.whekin.dev +VITE_BOT_API_URL=https://household-bot.whekin.dev diff --git a/docs/runbooks/vps-compose-deploy.md b/docs/runbooks/vps-compose-deploy.md index 08f5465..901483f 100644 --- a/docs/runbooks/vps-compose-deploy.md +++ b/docs/runbooks/vps-compose-deploy.md @@ -125,7 +125,6 @@ These can be adjusted later without changing the deployment shape. 6. Validate builds/tests where practical 7. Push branch and open PR - ## Runtime Env Files on VPS Expected files under `/opt/household-bot/env`: @@ -134,6 +133,7 @@ Expected files under `/opt/household-bot/env`: - `caddy.env` Templates live in `deploy/vps/*.env.example`. +- `miniapp.env` should set `VITE_BOT_API_URL` for the frontend build/runtime config. ## GitHub Actions Inputs / Secrets @@ -145,8 +145,9 @@ Recommended repository variables: - `VPS_BOT_URL` (default `https://household-bot.whekin.dev`) - `VPS_MINIAPP_URL` (default `https://household.whekin.dev`) -Required repository secret: +Required repository secrets: - `VPS_SSH_KEY` +- `VPS_KNOWN_HOSTS` Optional for webhook sync and smoke verification: - `TELEGRAM_BOT_TOKEN` diff --git a/packages/application/src/ad-hoc-notification-service.test.ts b/packages/application/src/ad-hoc-notification-service.test.ts index 4e45f2d..6a02af6 100644 --- a/packages/application/src/ad-hoc-notification-service.test.ts +++ b/packages/application/src/ad-hoc-notification-service.test.ts @@ -38,8 +38,8 @@ class NotificationRepositoryStub implements AdHocNotificationRepository { sentAt: null, cancelledAt: null, cancelledByMemberId: null, - createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'), - updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z') + createdAt: Temporal.Instant.from('2099-03-23T09:00:00Z'), + updatedAt: Temporal.Instant.from('2099-03-23T09:00:00Z') } this.notifications.set(id, record) return record @@ -191,7 +191,7 @@ describe('createAdHocNotificationService', () => { originalRequestText: 'Напомни Георгию завтра', notificationText: 'пошпынять Георгия о том, позвонил ли он', timezone: 'Asia/Tbilisi', - scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), + scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'), timePrecision: 'date_only_defaulted', deliveryMode: 'topic' }) @@ -222,7 +222,7 @@ describe('createAdHocNotificationService', () => { originalRequestText: 'remind everyone tomorrow', notificationText: 'pay rent', timezone: 'Asia/Tbilisi', - scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), + scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'), timePrecision: 'date_only_defaulted', deliveryMode: 'dm_all' }) @@ -246,7 +246,7 @@ describe('createAdHocNotificationService', () => { originalRequestText: 'remind tomorrow', notificationText: 'check rent', timezone: 'Asia/Tbilisi', - scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), + scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'), timePrecision: 'date_only_defaulted', deliveryMode: 'topic', friendlyTagAssignee: true @@ -273,7 +273,7 @@ describe('createAdHocNotificationService', () => { originalRequestText: 'remind tomorrow', notificationText: 'call landlord', timezone: 'Asia/Tbilisi', - scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), + scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'), timePrecision: 'date_only_defaulted', deliveryMode: 'topic', friendlyTagAssignee: false @@ -282,7 +282,7 @@ describe('createAdHocNotificationService', () => { const result = await service.cancelNotification({ notificationId: created.id, viewerMemberId: 'admin', - asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') + asOf: Temporal.Instant.from('2099-03-23T09:00:00Z') }) expect(result.status).toBe('cancelled') @@ -306,7 +306,7 @@ describe('createAdHocNotificationService', () => { originalRequestText: 'remind tomorrow', notificationText: 'call landlord', timezone: 'Asia/Tbilisi', - scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), + scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'), timePrecision: 'date_only_defaulted', deliveryMode: 'topic', friendlyTagAssignee: false @@ -315,7 +315,7 @@ describe('createAdHocNotificationService', () => { const items = await service.listUpcomingNotifications({ householdId: 'household-1', viewerMemberId: 'viewer', - asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') + asOf: Temporal.Instant.from('2099-03-23T09:00:00Z') }) expect(items).toHaveLength(1) @@ -342,7 +342,7 @@ describe('createAdHocNotificationService', () => { originalRequestText: 'remind tomorrow', notificationText: 'call landlord', timezone: 'Asia/Tbilisi', - scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), + scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'), timePrecision: 'date_only_defaulted', deliveryMode: 'topic', friendlyTagAssignee: false @@ -355,7 +355,7 @@ describe('createAdHocNotificationService', () => { timePrecision: 'exact', deliveryMode: 'dm_selected', dmRecipientMemberIds: ['alice', 'bob'], - asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') + asOf: Temporal.Instant.from('2099-03-23T09:00:00Z') }) expect(result.status).toBe('updated') diff --git a/packages/application/src/scheduled-dispatch-service.ts b/packages/application/src/scheduled-dispatch-service.ts index d207ff7..177f369 100644 --- a/packages/application/src/scheduled-dispatch-service.ts +++ b/packages/application/src/scheduled-dispatch-service.ts @@ -8,6 +8,17 @@ import type { } from '@household/ports' const BUILT_IN_DISPATCH_KINDS = ['utilities', 'rent_warning', 'rent_due'] as const +const DEFAULT_DUE_DISPATCH_SCAN_LIMIT = 25 +const MAX_DUE_DISPATCH_SCAN_LIMIT = 100 + +function normalizeDueDispatchLimit(limit: number | undefined): number { + const value = limit ?? DEFAULT_DUE_DISPATCH_SCAN_LIMIT + if (!Number.isInteger(value) || value <= 0) { + return DEFAULT_DUE_DISPATCH_SCAN_LIMIT + } + + return Math.min(value, MAX_DUE_DISPATCH_SCAN_LIMIT) +} function builtInDispatchDay( kind: (typeof BUILT_IN_DISPATCH_KINDS)[number], @@ -312,7 +323,7 @@ export function createScheduledDispatchService(input: { return input.repository.listDueScheduledDispatches({ dueBefore: inputValue?.asOf ?? nowInstant(), provider: input.scheduler.provider, - limit: inputValue?.limit ?? 25 + limit: normalizeDueDispatchLimit(inputValue?.limit) }) }, diff --git a/scripts/ops/vps-deploy.sh b/scripts/ops/vps-deploy.sh index 4eb5199..b0c21d0 100755 --- a/scripts/ops/vps-deploy.sh +++ b/scripts/ops/vps-deploy.sh @@ -22,6 +22,15 @@ require_var() { fi } +require_positive_int() { + local name="$1" + local value="${!name:-}" + if [[ ! "$value" =~ ^[0-9]+$ || "$value" -le 0 ]]; then + echo "Invalid positive integer for $name: $value" >&2 + exit 1 + fi +} + require_var BOT_IMAGE require_var MINIAPP_IMAGE require_file "$COMPOSE_FILE" @@ -38,6 +47,8 @@ export MINIAPP_IMAGE export ENV_DIR export SCHEDULER_POLL_INTERVAL_MS="${SCHEDULER_POLL_INTERVAL_MS:-60000}" export SCHEDULER_DUE_SCAN_LIMIT="${SCHEDULER_DUE_SCAN_LIMIT:-25}" +require_positive_int SCHEDULER_POLL_INTERVAL_MS +require_positive_int SCHEDULER_DUE_SCAN_LIMIT mkdir -p "$DEPLOY_ROOT"