From c3750147eb333f3e831d450272f911c73a5686ac Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 14:49:17 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20add=20plan=201=20=E2=80=94=20foundation?= =?UTF-8?q?=20&=20WhatsApp=20pairing=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-state of the plan: monorepo, all DB tables migrated, dev Docker stack running the bot service, and Telegram-driven WhatsApp pairing working end-to-end (QR delivered, scanned, account connected, groups synced, auto-reconnect on disconnect, restart-survival via useMultiFileAuthState). Plans 2-4 (reminder scheduling, web dashboard, production deploy) are referenced but not yet written. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-03-foundation-and-pairing.md | 2985 +++++++++++++++++ 1 file changed, 2985 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-foundation-and-pairing.md diff --git a/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md b/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md new file mode 100644 index 0000000..bb534ce --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md @@ -0,0 +1,2985 @@ +# Foundation & WhatsApp Pairing MVP — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up the monorepo, database schema, dev tooling, and a working `bot` service that lets the operator pair a WhatsApp account through Telegram (QR delivered, scanned, account marked connected, groups synced). End-to-end manual test passes against the dev mock account. + +**Architecture:** pnpm workspace + Turbo. `apps/bot` Node service uses Baileys for WhatsApp, grammy for Telegram, Drizzle for Postgres. Two shared packages: `packages/db` (schema + migrations) and `packages/shared` (types + helpers). Postgres lives external at `192.168.0.210`. Web app and reminder scheduling are deferred to later plans. + +**Tech Stack:** TypeScript, Node 22, pnpm, Turbo, Drizzle ORM, Postgres, Baileys (`@whiskeysockets/baileys`), grammy, qrcode, pino, zod, Vitest, Docker Compose. + +**Pre-flight checks before starting:** +- Postgres at `192.168.0.210` reachable from your dev machine. `pg_hba.conf` allows your dev machine's subnet *and* the Docker bridge (`172.16.0.0/12`). +- Two databases exist on that Postgres instance: `whatsapp_bot_dev` and `whatsapp_bot_prod`. Two roles with passwords. (`whatsapp_bot_prod` will be unused in this plan but creating it now keeps schemas aligned.) +- Two Telegram bots created via `@BotFather`: one for dev (e.g. `@cm_wabot_dev_bot`), one for prod. Save both tokens. +- One WhatsApp account dedicated as the dev mock — a spare phone or secondary number, NOT your brother's real account. +- Your Telegram user ID known (DM `@userinfobot` to see it). + +**Spec reference:** `docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md` + +--- + +## File structure produced by this plan + +``` +cm_whatsapp_bot_v1/ +├── .gitignore +├── .nvmrc Node 22 +├── package.json root (workspace root) +├── pnpm-workspace.yaml +├── turbo.json +├── tsconfig.base.json shared TS config +├── README.md +│ +├── apps/ +│ └── bot/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── vitest.config.ts +│ └── src/ +│ ├── index.ts bootstrap, graceful shutdown +│ ├── env.ts zod env validation +│ ├── logger.ts pino instance +│ ├── db.ts drizzle client (re-export from packages/db) +│ ├── health.ts internal HTTP server +│ ├── audit.ts audit_log writer +│ ├── telegram/ +│ │ ├── bot.ts grammy instance +│ │ ├── middleware/ +│ │ │ ├── whitelist.ts +│ │ │ └── audit.ts +│ │ └── commands/ +│ │ ├── start.ts +│ │ ├── help.ts +│ │ ├── pair.ts +│ │ ├── unpair.ts +│ │ ├── accounts.ts +│ │ └── groups.ts +│ └── whatsapp/ +│ ├── session-manager.ts +│ ├── session.ts +│ ├── qr-renderer.ts +│ └── group-sync.ts +│ +├── packages/ +│ ├── db/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── drizzle.config.ts +│ │ ├── src/ +│ │ │ ├── index.ts createClient, exported queries +│ │ │ ├── schema.ts all tables +│ │ │ └── seed.ts dev seed (operator row) +│ │ └── migrations/ generated by drizzle-kit +│ └── shared/ +│ ├── package.json +│ ├── tsconfig.json +│ └── src/ +│ ├── index.ts +│ ├── rrule.ts parse/validate/next helpers +│ ├── media-paths.ts deterministic /data/media paths +│ └── timezones.ts IANA validation helper +│ +├── docker/ +│ ├── bot.Dockerfile +│ └── web.Dockerfile placeholder for plan 3 +│ +├── docker-compose.base.yml +├── docker-compose.dev.yml +│ +├── envs/ +│ └── .env.example +├── .env.development not committed-to-public; in this repo private OK +│ +├── scripts/ +│ ├── dev.sh +│ ├── db.sh +│ ├── gen_auth_secret.sh +│ ├── link-account.sh (stub now; populated in plan 2) +│ └── publish.sh (stub; populated in plan 4) +│ +└── docs/ + └── superpowers/ + └── specs/ + └── manual-test-pairing.md manual test runbook +``` + +--- + +## Task 1: Initialize git remote and gitignore + +**Files:** +- Create: `/home/yiekheng/projects/cm_whatsapp_bot_v1/.gitignore` + +- [ ] **Step 1: Add the Gitea remote** + +```bash +cd /home/yiekheng/projects/cm_whatsapp_bot_v1 +git remote add origin http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git +git remote -v +``` + +Expected: `origin http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git (fetch)` and `(push)`. + +- [ ] **Step 2: Create `.gitignore`** + +```gitignore +# deps +node_modules/ +.pnpm-store/ + +# build outputs +dist/ +.next/ +.turbo/ +*.tsbuildinfo + +# env files: per project decision, .env.development and .env.production +# ARE committed to this private Gitea. Only ignore example overrides: +.env.local +.env.*.local + +# logs +*.log +npm-debug.log* +pnpm-debug.log* + +# editor +.vscode/ +.idea/ +*.swp +.DS_Store + +# runtime data (mounted volumes from compose) +dev-data/ +data/ + +# test coverage +coverage/ +.vitest-cache/ +``` + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git -c commit.gpgsign=false commit -m "chore: add .gitignore and configure remote" +``` + +--- + +## Task 2: Root workspace, Turbo, and TS config + +**Files:** +- Create: `package.json` +- Create: `pnpm-workspace.yaml` +- Create: `turbo.json` +- Create: `tsconfig.base.json` +- Create: `.nvmrc` + +- [ ] **Step 1: Create `.nvmrc`** + +``` +22 +``` + +- [ ] **Step 2: Create `pnpm-workspace.yaml`** + +```yaml +packages: + - "apps/*" + - "packages/*" +``` + +- [ ] **Step 3: Create `package.json`** + +```json +{ + "name": "cm-whatsapp-bot", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev --parallel", + "test": "turbo run test", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", + "db:generate": "pnpm --filter @cmbot/db generate", + "db:migrate": "pnpm --filter @cmbot/db migrate", + "db:studio": "pnpm --filter @cmbot/db studio" + }, + "devDependencies": { + "turbo": "^2.1.0", + "typescript": "^5.5.0" + } +} +``` + +- [ ] **Step 4: Create `turbo.json`** + +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "lint": {}, + "typecheck": { + "dependsOn": ["^build"] + } + } +} +``` + +- [ ] **Step 5: Create `tsconfig.base.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "exclude": ["node_modules", "dist", ".next", ".turbo"] +} +``` + +- [ ] **Step 6: Install root deps** + +```bash +pnpm install +``` + +Expected: pnpm creates `node_modules` and `pnpm-lock.yaml`. No errors. + +- [ ] **Step 7: Commit** + +```bash +git add package.json pnpm-workspace.yaml turbo.json tsconfig.base.json .nvmrc pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "chore: initialize pnpm workspace + Turbo" +``` + +--- + +## Task 3: Create `packages/shared` with rrule + path helpers + +**Files:** +- Create: `packages/shared/package.json` +- Create: `packages/shared/tsconfig.json` +- Create: `packages/shared/src/index.ts` +- Create: `packages/shared/src/rrule.ts` +- Create: `packages/shared/src/media-paths.ts` +- Create: `packages/shared/src/timezones.ts` +- Create: `packages/shared/src/rrule.test.ts` +- Create: `packages/shared/vitest.config.ts` + +- [ ] **Step 1: Write the failing test for rrule helpers** + +Create `packages/shared/src/rrule.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { parseRRule, nextOccurrence, validateMinInterval, MIN_INTERVAL_MS } from "./rrule.js"; + +describe("parseRRule", () => { + it("accepts a daily rule", () => { + expect(() => parseRRule("FREQ=DAILY;BYHOUR=9;BYMINUTE=0")).not.toThrow(); + }); + it("rejects invalid syntax", () => { + expect(() => parseRRule("not-a-rule")).toThrow(); + }); +}); + +describe("nextOccurrence", () => { + it("returns the next firing time after `after`", () => { + const rule = "FREQ=DAILY;BYHOUR=9;BYMINUTE=0"; + const after = new Date("2026-05-03T08:00:00Z"); + const next = nextOccurrence(rule, "Asia/Kuala_Lumpur", after); + expect(next).toBeInstanceOf(Date); + expect(next!.getTime()).toBeGreaterThan(after.getTime()); + }); + it("returns null when the rule has no further occurrences", () => { + const past = "FREQ=DAILY;COUNT=1;DTSTART=20200101T000000Z"; + expect(nextOccurrence(past, "Asia/Kuala_Lumpur", new Date())).toBeNull(); + }); +}); + +describe("validateMinInterval", () => { + it("accepts a daily rule (interval > 5 min)", () => { + expect(validateMinInterval("FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "Asia/Kuala_Lumpur")) + .toEqual({ ok: true }); + }); + it("rejects a rule firing every minute", () => { + const result = validateMinInterval("FREQ=MINUTELY", "Asia/Kuala_Lumpur"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toMatch(/minimum interval/i); + } + }); +}); + +describe("MIN_INTERVAL_MS", () => { + it("equals 5 minutes", () => { + expect(MIN_INTERVAL_MS).toBe(5 * 60 * 1000); + }); +}); +``` + +- [ ] **Step 2: Run the test (expect failure — module missing)** + +```bash +pnpm --filter @cmbot/shared test +``` + +Expected: fails with module-not-found errors. + +- [ ] **Step 3: Create `packages/shared/package.json`** + +```json +{ + "name": "@cmbot/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "lint": "echo 'lint placeholder'", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "rrule": "^2.8.1", + "luxon": "^3.5.0" + }, + "devDependencies": { + "@types/luxon": "^3.4.2", + "typescript": "^5.5.0", + "vitest": "^2.1.0" + } +} +``` + +- [ ] **Step 4: Create `packages/shared/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 5: Create `packages/shared/vitest.config.ts`** + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); +``` + +- [ ] **Step 6: Implement `packages/shared/src/rrule.ts`** + +```typescript +import { RRule, rrulestr } from "rrule"; +import { DateTime } from "luxon"; + +export const MIN_INTERVAL_MS = 5 * 60 * 1000; + +export function parseRRule(rule: string): RRule { + const parsed = rrulestr(rule); + if (!(parsed instanceof RRule)) { + throw new Error("Compound RRULE/RRSET not supported"); + } + return parsed; +} + +export function nextOccurrence(rule: string, timezone: string, after: Date): Date | null { + const parsed = parseRRule(rule); + // RRule doesn't natively respect IANA timezones; convert `after` to a wall-time + // probe in the target zone, then return the absolute UTC instant. + const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate(); + const next = parsed.after(afterInZone, false); + return next ?? null; +} + +export type IntervalCheck = { ok: true } | { ok: false; reason: string }; + +export function validateMinInterval(rule: string, timezone: string): IntervalCheck { + const parsed = parseRRule(rule); + // Check the gap between the next two occurrences from now. + const now = new Date(); + const first = parsed.after(now, false); + if (!first) return { ok: true }; // no future occurrences, vacuously ok + const second = parsed.after(first, false); + if (!second) return { ok: true }; + const gap = second.getTime() - first.getTime(); + if (gap < MIN_INTERVAL_MS) { + return { + ok: false, + reason: `Recurrence fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`, + }; + } + return { ok: true }; +} +``` + +- [ ] **Step 7: Implement `packages/shared/src/media-paths.ts`** + +```typescript +import { randomUUID } from "node:crypto"; + +export const MEDIA_ROOT = "/data/media"; + +export function newMediaPath(originalFilename: string, now: Date = new Date()): string { + const ext = (originalFilename.match(/\.[a-zA-Z0-9]{1,8}$/)?.[0] ?? "").toLowerCase(); + const yyyy = now.getUTCFullYear(); + const mm = String(now.getUTCMonth() + 1).padStart(2, "0"); + return `${yyyy}/${mm}/${randomUUID()}${ext}`; +} + +export function absoluteMediaPath(storagePath: string, root: string = MEDIA_ROOT): string { + if (storagePath.includes("..")) throw new Error("Invalid storage path"); + return `${root}/${storagePath}`; +} +``` + +- [ ] **Step 8: Implement `packages/shared/src/timezones.ts`** + +```typescript +import { DateTime } from "luxon"; + +export function isValidTimezone(tz: string): boolean { + return DateTime.local().setZone(tz).isValid; +} + +export const DEFAULT_TIMEZONE = "Asia/Kuala_Lumpur"; +``` + +- [ ] **Step 9: Implement `packages/shared/src/index.ts`** + +```typescript +export * from "./rrule.js"; +export * from "./media-paths.js"; +export * from "./timezones.js"; +``` + +- [ ] **Step 10: Install workspace deps and run tests** + +```bash +pnpm install +pnpm --filter @cmbot/shared test +``` + +Expected: tests pass. + +- [ ] **Step 11: Commit** + +```bash +git add packages/shared package.json pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(shared): add rrule, media-path, timezone helpers" +``` + +--- + +## Task 4: Create `packages/db` with Drizzle schema for all tables + +**Files:** +- Create: `packages/db/package.json` +- Create: `packages/db/tsconfig.json` +- Create: `packages/db/drizzle.config.ts` +- Create: `packages/db/src/index.ts` +- Create: `packages/db/src/schema.ts` +- Create: `packages/db/src/seed.ts` + +- [ ] **Step 1: Create `packages/db/package.json`** + +```json +{ + "name": "@cmbot/db", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "echo 'no unit tests'", + "lint": "echo 'lint placeholder'", + "typecheck": "tsc -p tsconfig.json --noEmit", + "generate": "drizzle-kit generate", + "migrate": "tsx src/migrate.ts", + "studio": "drizzle-kit studio", + "seed": "tsx src/seed.ts" + }, + "dependencies": { + "drizzle-orm": "^0.36.0", + "pg": "^8.13.0" + }, + "devDependencies": { + "@types/pg": "^8.11.10", + "drizzle-kit": "^0.28.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + } +} +``` + +- [ ] **Step 2: Create `packages/db/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 3: Create `packages/db/drizzle.config.ts`** + +```typescript +import { defineConfig } from "drizzle-kit"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error("DATABASE_URL must be set when running drizzle-kit"); +} + +export default defineConfig({ + schema: "./src/schema.ts", + out: "./migrations", + dialect: "postgresql", + dbCredentials: { url: databaseUrl }, + strict: true, + verbose: true, +}); +``` + +- [ ] **Step 4: Implement `packages/db/src/schema.ts`** (all tables from spec section 9) + +```typescript +import { + pgTable, + uuid, + text, + bigint, + integer, + boolean, + timestamp, + jsonb, + primaryKey, + uniqueIndex, + inet, +} from "drizzle-orm/pg-core"; + +export const operators = pgTable( + "operators", + { + id: uuid("id").primaryKey().defaultRandom(), + telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(), + displayName: text("display_name").notNull(), + role: text("role").notNull().default("admin"), + defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId), + }), +); + +export const whatsappAccounts = pgTable( + "whatsapp_accounts", + { + id: uuid("id").primaryKey().defaultRandom(), + operatorId: uuid("operator_id").notNull().references(() => operators.id), + label: text("label").notNull(), + phoneNumber: text("phone_number"), + status: text("status").notNull().default("pending"), + lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }), + lastQrAt: timestamp("last_qr_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + operatorLabelUnique: uniqueIndex("whatsapp_accounts_operator_label_uq").on(t.operatorId, t.label), + }), +); + +export const whatsappGroups = pgTable( + "whatsapp_groups", + { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id), + waGroupJid: text("wa_group_jid").notNull(), + name: text("name").notNull(), + participantCount: integer("participant_count").notNull().default(0), + isArchived: boolean("is_archived").notNull().default(false), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid), + }), +); + +export const mediaFiles = pgTable("media_files", { + id: uuid("id").primaryKey().defaultRandom(), + operatorId: uuid("operator_id").notNull().references(() => operators.id), + filenameOriginal: text("filename_original").notNull(), + mimeType: text("mime_type").notNull(), + sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(), + sha256: text("sha256").notNull(), + storagePath: text("storage_path").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const reminders = pgTable("reminders", { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id), + name: text("name").notNull(), + scheduleKind: text("schedule_kind").notNull(), + scheduledAt: timestamp("scheduled_at", { withTimezone: true }), + rrule: text("rrule"), + timezone: text("timezone").notNull(), + endsAt: timestamp("ends_at", { withTimezone: true }), + maxRuns: integer("max_runs"), + status: text("status").notNull().default("active"), + createdBy: uuid("created_by").notNull().references(() => operators.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const reminderTargets = pgTable( + "reminder_targets", + { + reminderId: uuid("reminder_id").notNull().references(() => reminders.id, { onDelete: "cascade" }), + groupId: uuid("group_id").notNull().references(() => whatsappGroups.id), + position: integer("position").notNull().default(0), + }, + (t) => ({ + pk: primaryKey({ columns: [t.reminderId, t.groupId] }), + }), +); + +export const reminderMessages = pgTable("reminder_messages", { + id: uuid("id").primaryKey().defaultRandom(), + reminderId: uuid("reminder_id").notNull().references(() => reminders.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + kind: text("kind").notNull(), + textContent: text("text_content"), + mediaId: uuid("media_id").references(() => mediaFiles.id), +}); + +export const reminderRuns = pgTable("reminder_runs", { + id: uuid("id").primaryKey().defaultRandom(), + reminderId: uuid("reminder_id").notNull().references(() => reminders.id), + firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(), + status: text("status").notNull(), + errorSummary: text("error_summary"), +}); + +export const reminderRunTargets = pgTable( + "reminder_run_targets", + { + runId: uuid("run_id").notNull().references(() => reminderRuns.id, { onDelete: "cascade" }), + groupId: uuid("group_id").notNull().references(() => whatsappGroups.id), + status: text("status").notNull(), + waMessageId: text("wa_message_id"), + error: text("error"), + latencyMs: integer("latency_ms"), + }, + (t) => ({ + pk: primaryKey({ columns: [t.runId, t.groupId] }), + }), +); + +export const auditLog = pgTable("audit_log", { + id: uuid("id").primaryKey().defaultRandom(), + operatorId: uuid("operator_id").references(() => operators.id), + source: text("source").notNull(), + action: text("action").notNull(), + targetType: text("target_type"), + targetId: uuid("target_id"), + payload: jsonb("payload").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const authSessions = pgTable("auth_sessions", { + id: uuid("id").primaryKey().defaultRandom(), + operatorId: uuid("operator_id").notNull().references(() => operators.id), + tokenHash: text("token_hash").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(), + ipAddress: inet("ip_address"), + userAgent: text("user_agent"), +}); + +// Convenience type exports +export type Operator = typeof operators.$inferSelect; +export type NewOperator = typeof operators.$inferInsert; +export type WhatsappAccount = typeof whatsappAccounts.$inferSelect; +export type NewWhatsappAccount = typeof whatsappAccounts.$inferInsert; +export type WhatsappGroup = typeof whatsappGroups.$inferSelect; +export type NewWhatsappGroup = typeof whatsappGroups.$inferInsert; +export type AuditLogEntry = typeof auditLog.$inferSelect; +export type NewAuditLogEntry = typeof auditLog.$inferInsert; +``` + +- [ ] **Step 5: Implement `packages/db/src/index.ts`** + +```typescript +import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import * as schema from "./schema.js"; + +export * from "./schema.js"; + +export type DB = NodePgDatabase; + +export function createClient(databaseUrl: string): { db: DB; pool: Pool } { + const pool = new Pool({ connectionString: databaseUrl }); + const db = drizzle(pool, { schema }); + return { db, pool }; +} +``` + +- [ ] **Step 6: Implement `packages/db/src/migrate.ts`** + +```typescript +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { createClient } from "./index.js"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + console.error("DATABASE_URL not set"); + process.exit(1); +} + +const { db, pool } = createClient(databaseUrl); +console.log("Applying migrations..."); +await migrate(db, { migrationsFolder: "./migrations" }); +console.log("Migrations applied."); +await pool.end(); +``` + +- [ ] **Step 7: Implement `packages/db/src/seed.ts`** + +```typescript +import { createClient, operators } from "./index.js"; + +const databaseUrl = process.env.DATABASE_URL; +const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID; +const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator"; + +if (!databaseUrl) { + console.error("DATABASE_URL not set"); + process.exit(1); +} +if (!operatorTelegramId) { + console.error("SEED_OPERATOR_TELEGRAM_ID not set"); + process.exit(1); +} + +const { db, pool } = createClient(databaseUrl); + +await db + .insert(operators) + .values({ + telegramUserId: Number(operatorTelegramId), + displayName: operatorName, + role: "admin", + defaultTimezone: "Asia/Kuala_Lumpur", + }) + .onConflictDoNothing(); + +console.log(`Seeded operator with telegram_user_id=${operatorTelegramId}`); +await pool.end(); +``` + +- [ ] **Step 8: Generate the initial migration** + +```bash +pnpm install +DATABASE_URL=postgres://placeholder pnpm --filter @cmbot/db generate +``` + +Expected: `packages/db/migrations/0000_*.sql` is generated. + +- [ ] **Step 9: Commit** + +```bash +git add packages/db pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(db): add drizzle schema for all tables + initial migration" +``` + +--- + +## Task 5: Create `.env.example` and dev env file + +**Files:** +- Create: `envs/.env.example` +- Create: `.env.development` + +- [ ] **Step 1: Create `envs/.env.example`** + +```bash +# === Postgres === +# Dev DB on the home Postgres at 192.168.0.210 +DATABASE_URL=postgres://USER:PASS@192.168.0.210:5432/whatsapp_bot_dev + +# === Telegram === +# Dev bot token from @BotFather +TELEGRAM_BOT_TOKEN= +# Comma-separated Telegram user IDs allowed to interact with the bot +TELEGRAM_OPERATOR_WHITELIST= +# Telegram chat ID where QR codes & system alerts are delivered (usually same +# as the operator's user ID) +TELEGRAM_QR_CHAT_ID= + +# === App data paths === +# Mount as a Docker volume in compose; absolute paths inside the container +DATA_DIR=/data +SESSIONS_DIR=/data/sessions +MEDIA_DIR=/data/media + +# === Bot service === +BOT_HEALTH_PORT=8081 +BOT_LOG_LEVEL=info + +# === Seed (used by scripts/db.sh seed) === +SEED_OPERATOR_TELEGRAM_ID= +SEED_OPERATOR_NAME=Operator + +# === Web (for plan 3; placeholder now) === +WEB_PORT=3000 +AUTH_SECRET= +``` + +- [ ] **Step 2: Create `.env.development`** (with your actual dev values; this file is committed per the project's private-repo decision but contains development credentials only) + +```bash +# Fill in real values for your dev environment +DATABASE_URL=postgres://YOUR_DEV_USER:YOUR_DEV_PASS@192.168.0.210:5432/whatsapp_bot_dev +TELEGRAM_BOT_TOKEN=YOUR_DEV_BOT_TOKEN +TELEGRAM_OPERATOR_WHITELIST=YOUR_TELEGRAM_USER_ID +TELEGRAM_QR_CHAT_ID=YOUR_TELEGRAM_USER_ID + +DATA_DIR=/data +SESSIONS_DIR=/data/sessions +MEDIA_DIR=/data/media + +BOT_HEALTH_PORT=8081 +BOT_LOG_LEVEL=debug + +SEED_OPERATOR_TELEGRAM_ID=YOUR_TELEGRAM_USER_ID +SEED_OPERATOR_NAME=Yiekheng (dev) + +WEB_PORT=3000 +AUTH_SECRET=replace-with-output-of-gen_auth_secret.sh +``` + +- [ ] **Step 3: Commit `.env.example` only (you'll fill `.env.development` after generating an auth secret in task 6)** + +```bash +git add envs/.env.example +git -c commit.gpgsign=false commit -m "chore: add .env.example documenting all keys" +``` + +--- + +## Task 6: Create scripts directory (dev.sh, db.sh, gen_auth_secret.sh, stub publish.sh) + +**Files:** +- Create: `scripts/dev.sh` +- Create: `scripts/db.sh` +- Create: `scripts/gen_auth_secret.sh` +- Create: `scripts/publish.sh` (stub) +- Create: `scripts/link-account.sh` (stub) + +- [ ] **Step 1: Create `scripts/gen_auth_secret.sh`** + +```bash +#!/usr/bin/env bash +# Generate a 32-byte (64 hex chars) AUTH_SECRET for web session signing. +set -euo pipefail + +usage() { + cat <<'EOF' +Generate AUTH_SECRET. + +Usage: + scripts/gen_auth_secret.sh Print a fresh secret to stdout. + scripts/gen_auth_secret.sh --write Set AUTH_SECRET= in ./.env.development + (creates if missing, replaces if present). + scripts/gen_auth_secret.sh --write PATH Same, against an explicit env path. +EOF +} + +generate() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + else + head -c 32 /dev/urandom | xxd -p -c 64 + fi +} + +write_into() { + local target="$1" + local secret + secret="$(generate)" + if [[ -f "${target}" ]] && grep -q '^AUTH_SECRET=' "${target}"; then + local tmp + tmp="$(mktemp)" + awk -v s="${secret}" ' + /^AUTH_SECRET=/ { print "AUTH_SECRET=" s; next } + { print } + ' "${target}" > "${tmp}" + mv "${tmp}" "${target}" + echo "Replaced AUTH_SECRET in ${target}" + else + [[ -f "${target}" ]] || touch "${target}" + if [[ -s "${target}" && -n "$(tail -c 1 "${target}")" ]]; then + printf '\n' >> "${target}" + fi + printf 'AUTH_SECRET=%s\n' "${secret}" >> "${target}" + echo "Appended AUTH_SECRET to ${target}" + fi +} + +case "${1:-}" in + -h|--help) usage ;; + --write) + target="${2:-.env.development}" + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + [[ "${target}" = /* ]] || target="${ROOT_DIR}/${target}" + write_into "${target}" + ;; + "") generate ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; +esac +``` + +- [ ] **Step 2: Create `scripts/dev.sh`** + +```bash +#!/usr/bin/env bash +# Lifecycle for the dev stack (bot service, dev DB is external). +set -euo pipefail + +usage() { + cat <<'EOF' +Lifecycle for the local dev stack. + +Usage: + scripts/dev.sh up Start all dev services in the background. + scripts/dev.sh down Stop the stack. + scripts/dev.sh logs Tail logs. + scripts/dev.sh status Print 'OK' if the stack is running, else exit 1. + scripts/dev.sh build Build images without starting containers. + +Environment: + NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group). +EOF +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +SUDO="sudo" +[[ "${NO_SUDO:-0}" == "1" ]] && SUDO="" +COMPOSE=(${SUDO} docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml) + +case "${1:-}" in + -h|--help|help) usage; exit 0 ;; + "") usage >&2; exit 1 ;; +esac + +if [[ ! -f .env.development ]]; then + echo "ERROR: .env.development not found at repo root." >&2 + echo " Copy envs/.env.example and fill in real values." >&2 + exit 2 +fi + +case "${1:-}" in + up) + "${COMPOSE[@]}" up -d --build + "${COMPOSE[@]}" ps + ;; + down) + "${COMPOSE[@]}" down --remove-orphans + ;; + logs) + "${COMPOSE[@]}" logs -f + ;; + status) + if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^bot$'; then + echo OK + else + echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2 + exit 1 + fi + ;; + build) + "${COMPOSE[@]}" build + ;; + *) + echo "unknown command: $1" >&2 + usage >&2 + exit 1 + ;; +esac +``` + +- [ ] **Step 3: Create `scripts/db.sh`** + +```bash +#!/usr/bin/env bash +# Drizzle migration wrapper. Operates on the DB pointed to by .env.development +# (or PATH passed via --env ). +set -euo pipefail + +usage() { + cat <<'EOF' +Drizzle migration helper. + +Usage: + scripts/db.sh migrate Apply pending migrations to DATABASE_URL. + scripts/db.sh generate Generate a new migration from schema changes. + scripts/db.sh studio Open drizzle-kit studio (DB browser). + scripts/db.sh seed Seed dev data (operator row). + scripts/db.sh reset Drop and recreate ALL tables (dev only; + refuses to run if DATABASE_URL points at + 'whatsapp_bot_prod'). + +Environment: + ENV_FILE Override env file (default: .env.development). +EOF +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +ENV_FILE="${ENV_FILE:-.env.development}" +if [[ ! -f "${ENV_FILE}" ]]; then + echo "ERROR: ${ENV_FILE} not found." >&2 + exit 2 +fi + +set -a +# shellcheck disable=SC1090 +source "${ENV_FILE}" +set +a + +case "${1:-}" in + -h|--help) usage ;; + migrate) pnpm --filter @cmbot/db migrate ;; + generate) pnpm --filter @cmbot/db generate ;; + studio) pnpm --filter @cmbot/db studio ;; + seed) pnpm --filter @cmbot/db seed ;; + reset) + if [[ "${DATABASE_URL}" == *whatsapp_bot_prod* ]]; then + echo "ERROR: refusing to reset prod database." >&2 + exit 2 + fi + read -r -p "About to DROP all tables in ${DATABASE_URL}. Type 'yes' to continue: " confirm + [[ "${confirm}" == "yes" ]] || { echo "Aborted."; exit 1; } + pnpm exec tsx -e " + import { Pool } from 'pg'; + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + await pool.query(\"DROP SCHEMA public CASCADE; CREATE SCHEMA public;\"); + await pool.query(\"DROP SCHEMA IF EXISTS pgboss CASCADE;\"); + await pool.end(); + console.log('Schema reset.'); + " + pnpm --filter @cmbot/db migrate + ;; + "") usage >&2; exit 1 ;; + *) echo "Unknown command: $1" >&2; usage >&2; exit 1 ;; +esac +``` + +- [ ] **Step 4: Create `scripts/publish.sh` (stub for plan 4)** + +```bash +#!/usr/bin/env bash +# Build and push images to the Gitea registry. Implemented in plan 4. +echo "scripts/publish.sh: not yet implemented (see plan 4)" >&2 +exit 1 +``` + +- [ ] **Step 5: Create `scripts/link-account.sh` (stub for plan 2)** + +```bash +#!/usr/bin/env bash +# CLI helper to start a WA pairing flow without going through Telegram. +# Implemented in plan 2. +echo "scripts/link-account.sh: not yet implemented (see plan 2)" >&2 +exit 1 +``` + +- [ ] **Step 6: Make all scripts executable** + +```bash +chmod +x scripts/*.sh +``` + +- [ ] **Step 7: Generate AUTH_SECRET into `.env.development` and commit env files** + +```bash +scripts/gen_auth_secret.sh --write +git add scripts/ .env.development +git -c commit.gpgsign=false commit -m "chore: add dev/db scripts and dev env file" +``` + +--- + +## Task 7: Add bot Dockerfile and docker-compose files + +**Files:** +- Create: `docker/bot.Dockerfile` +- Create: `docker/web.Dockerfile` (placeholder) +- Create: `docker-compose.base.yml` +- Create: `docker-compose.dev.yml` + +- [ ] **Step 1: Create `docker/bot.Dockerfile`** + +```dockerfile +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/bot/package.json apps/bot/ +COPY packages/db/package.json packages/db/ +COPY packages/shared/package.json packages/shared/ +RUN pnpm install --frozen-lockfile + +FROM base AS build +COPY --from=deps /app/node_modules /app/node_modules +COPY --from=deps /app/apps/bot/node_modules /app/apps/bot/node_modules +COPY --from=deps /app/packages/db/node_modules /app/packages/db/node_modules +COPY --from=deps /app/packages/shared/node_modules /app/packages/shared/node_modules +COPY tsconfig.base.json turbo.json ./ +COPY apps/bot apps/bot +COPY packages/db packages/db +COPY packages/shared packages/shared +RUN pnpm --filter @cmbot/shared build && pnpm --filter @cmbot/db build && pnpm --filter @cmbot/bot build + +FROM base AS runtime +ENV NODE_ENV=production +COPY --from=build /app/node_modules /app/node_modules +COPY --from=build /app/apps/bot/dist /app/apps/bot/dist +COPY --from=build /app/apps/bot/node_modules /app/apps/bot/node_modules +COPY --from=build /app/apps/bot/package.json /app/apps/bot/ +COPY --from=build /app/packages/db /app/packages/db +COPY --from=build /app/packages/shared /app/packages/shared +EXPOSE 8081 +CMD ["node", "apps/bot/dist/index.js"] +``` + +- [ ] **Step 2: Create `docker/web.Dockerfile`** (placeholder, fleshed out in plan 3) + +```dockerfile +FROM node:22-alpine +WORKDIR /app +CMD ["echo", "web service: not yet implemented (see plan 3)"] +``` + +- [ ] **Step 3: Create `docker-compose.base.yml`** + +```yaml +services: + bot: + build: + context: . + dockerfile: docker/bot.Dockerfile + image: cm-whatsapp-bot:local + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + TELEGRAM_OPERATOR_WHITELIST: ${TELEGRAM_OPERATOR_WHITELIST} + TELEGRAM_QR_CHAT_ID: ${TELEGRAM_QR_CHAT_ID} + DATA_DIR: ${DATA_DIR} + SESSIONS_DIR: ${SESSIONS_DIR} + MEDIA_DIR: ${MEDIA_DIR} + BOT_HEALTH_PORT: ${BOT_HEALTH_PORT} + BOT_LOG_LEVEL: ${BOT_LOG_LEVEL} + networks: + - cmbot + +networks: + cmbot: + driver: bridge +``` + +- [ ] **Step 4: Create `docker-compose.dev.yml`** + +```yaml +services: + bot: + image: cm-whatsapp-bot:dev + build: + context: . + dockerfile: docker/bot.Dockerfile + target: build + command: ["pnpm", "--filter", "@cmbot/bot", "dev"] + volumes: + - ./apps/bot/src:/app/apps/bot/src:ro + - ./packages/db/src:/app/packages/db/src:ro + - ./packages/shared/src:/app/packages/shared/src:ro + - ./dev-data:/data + ports: + - "127.0.0.1:8081:8081" + environment: + NODE_ENV: development +``` + +- [ ] **Step 5: Commit** + +```bash +git add docker/ docker-compose.base.yml docker-compose.dev.yml +git -c commit.gpgsign=false commit -m "chore: add Dockerfiles and base+dev compose files" +``` + +--- + +## Task 8: Bot service skeleton (env, logger, db client, health, shutdown) + +**Files:** +- Create: `apps/bot/package.json` +- Create: `apps/bot/tsconfig.json` +- Create: `apps/bot/vitest.config.ts` +- Create: `apps/bot/src/env.ts` +- Create: `apps/bot/src/logger.ts` +- Create: `apps/bot/src/db.ts` +- Create: `apps/bot/src/health.ts` +- Create: `apps/bot/src/index.ts` +- Create: `apps/bot/src/env.test.ts` + +- [ ] **Step 1: Create `apps/bot/package.json`** + +```json +{ + "name": "@cmbot/bot", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "test": "vitest run", + "lint": "echo 'lint placeholder'", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@cmbot/db": "workspace:*", + "@cmbot/shared": "workspace:*", + "@whiskeysockets/baileys": "^6.7.7", + "grammy": "^1.31.0", + "pino": "^9.5.0", + "pino-pretty": "^11.3.0", + "qrcode": "^1.5.4", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/qrcode": "^1.5.5", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "vitest": "^2.1.0" + } +} +``` + +- [ ] **Step 2: Create `apps/bot/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 3: Create `apps/bot/vitest.config.ts`** + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); +``` + +- [ ] **Step 4: Write the failing test for env validation** + +Create `apps/bot/src/env.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { parseEnv } from "./env.js"; + +const valid = { + DATABASE_URL: "postgres://u:p@h:5432/db", + TELEGRAM_BOT_TOKEN: "123:abc", + TELEGRAM_OPERATOR_WHITELIST: "111,222", + TELEGRAM_QR_CHAT_ID: "111", + DATA_DIR: "/data", + SESSIONS_DIR: "/data/sessions", + MEDIA_DIR: "/data/media", + BOT_HEALTH_PORT: "8081", + BOT_LOG_LEVEL: "info", +}; + +describe("parseEnv", () => { + it("parses a valid env", () => { + const env = parseEnv(valid); + expect(env.TELEGRAM_OPERATOR_WHITELIST).toEqual([111, 222]); + expect(env.TELEGRAM_QR_CHAT_ID).toBe(111); + expect(env.BOT_HEALTH_PORT).toBe(8081); + }); + + it("rejects missing DATABASE_URL", () => { + const { DATABASE_URL: _, ...rest } = valid; + expect(() => parseEnv(rest)).toThrow(); + }); + + it("rejects empty whitelist", () => { + expect(() => parseEnv({ ...valid, TELEGRAM_OPERATOR_WHITELIST: "" })).toThrow(); + }); + + it("rejects malformed port", () => { + expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow(); + }); +}); +``` + +- [ ] **Step 5: Run test (expect failure — module missing)** + +```bash +pnpm install +pnpm --filter @cmbot/bot test +``` + +Expected: fails (module not found). + +- [ ] **Step 6: Implement `apps/bot/src/env.ts`** + +```typescript +import { z } from "zod"; + +const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s)); + +const envSchema = z.object({ + DATABASE_URL: z.string().url(), + TELEGRAM_BOT_TOKEN: z.string().min(1), + TELEGRAM_OPERATOR_WHITELIST: z + .string() + .min(1) + .transform((s) => s.split(",").map((x) => Number(x.trim()))) + .pipe(z.array(z.number().int().positive()).min(1)), + TELEGRAM_QR_CHAT_ID: numberFromString, + DATA_DIR: z.string().min(1), + SESSIONS_DIR: z.string().min(1), + MEDIA_DIR: z.string().min(1), + BOT_HEALTH_PORT: numberFromString, + BOT_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), +}); + +export type Env = z.infer; + +export function parseEnv(input: Record): Env { + return envSchema.parse(input); +} + +export const env = parseEnv(process.env); +``` + +- [ ] **Step 7: Run test (expect pass)** + +```bash +pnpm --filter @cmbot/bot test +``` + +Expected: 4 tests pass. + +- [ ] **Step 8: Implement `apps/bot/src/logger.ts`** + +```typescript +import pino from "pino"; +import { env } from "./env.js"; + +export const logger = pino({ + level: env.BOT_LOG_LEVEL, + ...(process.env.NODE_ENV !== "production" + ? { transport: { target: "pino-pretty", options: { colorize: true } } } + : {}), +}); +``` + +- [ ] **Step 9: Implement `apps/bot/src/db.ts`** + +```typescript +import { createClient, type DB } from "@cmbot/db"; +import type { Pool } from "pg"; +import { env } from "./env.js"; + +const { db, pool } = createClient(env.DATABASE_URL); + +export { db, pool }; +export type { DB }; +``` + +- [ ] **Step 10: Implement `apps/bot/src/health.ts`** + +```typescript +import { createServer, type Server } from "node:http"; +import { sql } from "drizzle-orm"; +import { db } from "./db.js"; +import { env } from "./env.js"; +import { logger } from "./logger.js"; + +export type HealthStatus = { + ok: boolean; + uptimeSec: number; + db: "ok" | "error"; + sessions?: Record; +}; + +let started = Date.now(); +let getSessionCounts: () => Record = () => ({}); + +export function setSessionCountsProvider(fn: () => Record): void { + getSessionCounts = fn; +} + +export async function buildHealth(): Promise { + let dbStatus: "ok" | "error" = "ok"; + try { + await db.execute(sql`select 1`); + } catch (err) { + logger.warn({ err }, "health: db ping failed"); + dbStatus = "error"; + } + return { + ok: dbStatus === "ok", + uptimeSec: Math.round((Date.now() - started) / 1000), + db: dbStatus, + sessions: getSessionCounts(), + }; +} + +export function startHealthServer(): Server { + const server = createServer(async (req, res) => { + if (req.url !== "/health") { + res.writeHead(404).end("not found"); + return; + } + const status = await buildHealth(); + res.writeHead(status.ok ? 200 : 503, { "content-type": "application/json" }); + res.end(JSON.stringify(status)); + }); + server.listen(env.BOT_HEALTH_PORT, () => { + logger.info({ port: env.BOT_HEALTH_PORT }, "health server listening"); + }); + return server; +} +``` + +- [ ] **Step 11: Implement `apps/bot/src/index.ts`** + +```typescript +import { logger } from "./logger.js"; +import { pool } from "./db.js"; +import { startHealthServer } from "./health.js"; + +async function main(): Promise { + logger.info("bot starting"); + const health = startHealthServer(); + + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, "shutting down"); + health.close(); + await pool.end(); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + logger.info("bot ready"); +} + +main().catch((err) => { + logger.fatal({ err }, "bot failed to start"); + process.exit(1); +}); +``` + +- [ ] **Step 12: Run typecheck** + +```bash +pnpm --filter @cmbot/bot typecheck +``` + +Expected: no errors. + +- [ ] **Step 13: Commit** + +```bash +git add apps/bot pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(bot): scaffold env, logger, db, health, shutdown" +``` + +--- + +## Task 9: Apply migrations to dev DB and verify connectivity + +- [ ] **Step 1: Apply migrations** + +```bash +scripts/db.sh migrate +``` + +Expected: "Migrations applied." If you get a connection error, fix `pg_hba.conf` or `DATABASE_URL` first. + +- [ ] **Step 2: Seed the operator row** + +```bash +scripts/db.sh seed +``` + +Expected: "Seeded operator with telegram_user_id=YOUR_ID". + +- [ ] **Step 3: Open studio to verify tables exist** + +```bash +scripts/db.sh studio +``` + +Expected: drizzle-kit studio launches in your browser; you can see all 11 tables and the seeded operator row. + +- [ ] **Step 4: Run bot locally outside Docker (sanity check)** + +```bash +set -a; source .env.development; set +a +pnpm --filter @cmbot/bot dev +``` + +Expected logs: +``` +{ "msg": "bot starting" } +{ "port": 8081, "msg": "health server listening" } +{ "msg": "bot ready" } +``` + +In another terminal: + +```bash +curl -s http://localhost:8081/health | jq +``` + +Expected: `{"ok": true, "uptimeSec": , "db": "ok", "sessions": {}}`. + +Stop with Ctrl-C. + +- [ ] **Step 5: Run bot inside Docker** + +```bash +scripts/dev.sh up +scripts/dev.sh logs +``` + +Expected: same logs, in container. + +- [ ] **Step 6: Verify health from Docker** + +```bash +curl -s http://localhost:8081/health | jq +``` + +Expected: same JSON. Then `scripts/dev.sh down`. + +- [ ] **Step 7: Commit nothing (this task is verification only)** + +--- + +## Task 10: Audit log writer + +**Files:** +- Create: `apps/bot/src/audit.ts` +- Create: `apps/bot/src/audit.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/bot/src/audit.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import type { DB } from "./db.js"; +import { writeAuditLog } from "./audit.js"; + +describe("writeAuditLog", () => { + it("inserts a row with normalized fields", async () => { + const inserted: unknown[] = []; + const fakeDb = { + insert: () => ({ + values: (v: unknown) => { + inserted.push(v); + return Promise.resolve(); + }, + }), + } as unknown as DB; + + await writeAuditLog(fakeDb, { + operatorId: null, + source: "telegram", + action: "test.event", + payload: { foo: "bar" }, + }); + + expect(inserted).toHaveLength(1); + expect(inserted[0]).toMatchObject({ + operatorId: null, + source: "telegram", + action: "test.event", + payload: { foo: "bar" }, + }); + }); +}); +``` + +- [ ] **Step 2: Run test (expect failure)** + +```bash +pnpm --filter @cmbot/bot test +``` + +- [ ] **Step 3: Implement `apps/bot/src/audit.ts`** + +```typescript +import { auditLog, type DB, type NewAuditLogEntry } from "@cmbot/db"; + +export type AuditInput = { + operatorId: string | null; + source: "web" | "telegram" | "system"; + action: string; + targetType?: string | null; + targetId?: string | null; + payload?: Record; +}; + +export async function writeAuditLog(db: DB, input: AuditInput): Promise { + const row: NewAuditLogEntry = { + operatorId: input.operatorId, + source: input.source, + action: input.action, + targetType: input.targetType ?? null, + targetId: input.targetId ?? null, + payload: input.payload ?? {}, + }; + await db.insert(auditLog).values(row); +} +``` + +- [ ] **Step 4: Run test (expect pass)** + +```bash +pnpm --filter @cmbot/bot test +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/bot/src/audit.ts apps/bot/src/audit.test.ts +git -c commit.gpgsign=false commit -m "feat(bot): add audit log writer" +``` + +--- + +## Task 11: Telegram bot foundation (grammy, whitelist, /start, /help) + +**Files:** +- Create: `apps/bot/src/telegram/bot.ts` +- Create: `apps/bot/src/telegram/middleware/whitelist.ts` +- Create: `apps/bot/src/telegram/middleware/audit.ts` +- Create: `apps/bot/src/telegram/commands/start.ts` +- Create: `apps/bot/src/telegram/commands/help.ts` +- Create: `apps/bot/src/telegram/middleware/whitelist.test.ts` +- Modify: `apps/bot/src/index.ts` (wire bot + start polling) + +- [ ] **Step 1: Write the failing test for whitelist middleware** + +Create `apps/bot/src/telegram/middleware/whitelist.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { makeWhitelistMiddleware } from "./whitelist.js"; + +function ctx(userId: number | undefined) { + return { + from: userId === undefined ? undefined : { id: userId }, + reply: vi.fn().mockResolvedValue(undefined), + } as unknown as { from?: { id: number }; reply: ReturnType }; +} + +describe("makeWhitelistMiddleware", () => { + it("calls next for whitelisted user", async () => { + const mw = makeWhitelistMiddleware([42]); + const c = ctx(42); + const next = vi.fn().mockResolvedValue(undefined); + await mw(c as never, next); + expect(next).toHaveBeenCalledOnce(); + expect(c.reply).not.toHaveBeenCalled(); + }); + + it("rejects non-whitelisted user with reply", async () => { + const mw = makeWhitelistMiddleware([42]); + const c = ctx(99); + const next = vi.fn(); + await mw(c as never, next); + expect(next).not.toHaveBeenCalled(); + expect(c.reply).toHaveBeenCalledWith(expect.stringMatching(/private/i)); + }); + + it("rejects user-less updates silently", async () => { + const mw = makeWhitelistMiddleware([42]); + const c = ctx(undefined); + const next = vi.fn(); + await mw(c as never, next); + expect(next).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test (expect failure)** + +```bash +pnpm --filter @cmbot/bot test +``` + +- [ ] **Step 3: Implement `apps/bot/src/telegram/middleware/whitelist.ts`** + +```typescript +import type { Context, MiddlewareFn } from "grammy"; + +export function makeWhitelistMiddleware(allowedUserIds: number[]): MiddlewareFn { + const allowed = new Set(allowedUserIds); + return async (ctx, next) => { + const userId = ctx.from?.id; + if (userId === undefined) return; // ignore updates without a user + if (!allowed.has(userId)) { + await ctx.reply("Sorry, this bot is private."); + return; + } + await next(); + }; +} +``` + +- [ ] **Step 4: Run test (expect pass)** + +```bash +pnpm --filter @cmbot/bot test +``` + +- [ ] **Step 5: Implement `apps/bot/src/telegram/middleware/audit.ts`** + +```typescript +import type { Context, MiddlewareFn } from "grammy"; +import { db } from "../../db.js"; +import { writeAuditLog } from "../../audit.js"; +import { logger } from "../../logger.js"; + +export const auditMiddleware: MiddlewareFn = async (ctx, next) => { + const text = ctx.message?.text; + if (text?.startsWith("/")) { + try { + await writeAuditLog(db, { + operatorId: null, // resolved later when commands look up the operator row + source: "telegram", + action: `tg.command.${text.split(" ")[0]?.slice(1) ?? "unknown"}`, + payload: { from: ctx.from?.id, text }, + }); + } catch (err) { + logger.warn({ err }, "audit middleware: failed to write"); + } + } + await next(); +}; +``` + +- [ ] **Step 6: Implement `apps/bot/src/telegram/commands/start.ts`** + +```typescript +import type { Context } from "grammy"; + +export async function handleStart(ctx: Context): Promise { + await ctx.reply( + "👋 cm WhatsApp Reminder Bot is online.\n\n" + + "Type /help to see available commands.", + ); +} +``` + +- [ ] **Step 7: Implement `apps/bot/src/telegram/commands/help.ts`** + +```typescript +import type { Context } from "grammy"; + +export async function handleHelp(ctx: Context): Promise { + await ctx.reply( + "Available commands:\n\n" + + "/start — show the welcome message\n" + + "/help — show this help\n" + + "/pair