mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
fix: address CI and PR review feedback
Co-authored-by: claw <stanislavkalishin+claw@gmail.com>
This commit is contained in:
24
.github/workflows/cd-vps.yml
vendored
24
.github/workflows/cd-vps.yml
vendored
@@ -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
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
||||
: 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
BOT_API_URL=https://household-bot.whekin.dev
|
||||
VITE_BOT_API_URL=https://household-bot.whekin.dev
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user