# 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