cm_whatsapp_bot_v1/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md
yiekheng c3750147eb docs: add plan 1 — foundation & WhatsApp pairing MVP
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) <noreply@anthropic.com>
2026-05-09 14:49:17 +08:00

84 KiB

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

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
# 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
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
packages:
  - "apps/*"
  - "packages/*"
  • Step 3: Create package.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
{
  "$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
{
  "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
pnpm install

Expected: pnpm creates node_modules and pnpm-lock.yaml. No errors.

  • Step 7: Commit
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:

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)
pnpm --filter @cmbot/shared test

Expected: fails with module-not-found errors.

  • Step 3: Create packages/shared/package.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
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
  • Step 5: Create packages/shared/vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["src/**/*.test.ts"],
  },
});
  • Step 6: Implement packages/shared/src/rrule.ts
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
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
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
export * from "./rrule.js";
export * from "./media-paths.js";
export * from "./timezones.js";
  • Step 10: Install workspace deps and run tests
pnpm install
pnpm --filter @cmbot/shared test

Expected: tests pass.

  • Step 11: Commit
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

{
  "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
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
  • Step 3: Create packages/db/drizzle.config.ts
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)
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
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<typeof schema>;

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
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
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
pnpm install
DATABASE_URL=postgres://placeholder pnpm --filter @cmbot/db generate

Expected: packages/db/migrations/0000_*.sql is generated.

  • Step 9: Commit
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

# === 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)
# 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)
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

#!/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
#!/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
#!/usr/bin/env bash
# Drizzle migration wrapper. Operates on the DB pointed to by .env.development
# (or PATH passed via --env <file>).
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)
#!/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)
#!/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
chmod +x scripts/*.sh
  • Step 7: Generate AUTH_SECRET into .env.development and commit env files
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

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)
FROM node:22-alpine
WORKDIR /app
CMD ["echo", "web service: not yet implemented (see plan 3)"]
  • Step 3: Create docker-compose.base.yml
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
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
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

{
  "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
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
  • Step 3: Create apps/bot/vitest.config.ts
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:

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)
pnpm install
pnpm --filter @cmbot/bot test

Expected: fails (module not found).

  • Step 6: Implement apps/bot/src/env.ts
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<typeof envSchema>;

export function parseEnv(input: Record<string, string | undefined>): Env {
  return envSchema.parse(input);
}

export const env = parseEnv(process.env);
  • Step 7: Run test (expect pass)
pnpm --filter @cmbot/bot test

Expected: 4 tests pass.

  • Step 8: Implement apps/bot/src/logger.ts
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
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
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<string, number>;
};

let started = Date.now();
let getSessionCounts: () => Record<string, number> = () => ({});

export function setSessionCountsProvider(fn: () => Record<string, number>): void {
  getSessionCounts = fn;
}

export async function buildHealth(): Promise<HealthStatus> {
  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
import { logger } from "./logger.js";
import { pool } from "./db.js";
import { startHealthServer } from "./health.js";

async function main(): Promise<void> {
  logger.info("bot starting");
  const health = startHealthServer();

  const shutdown = async (signal: string): Promise<void> => {
    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
pnpm --filter @cmbot/bot typecheck

Expected: no errors.

  • Step 13: Commit
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
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
scripts/db.sh seed

Expected: "Seeded operator with telegram_user_id=YOUR_ID".

  • Step 3: Open studio to verify tables exist
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)
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:

curl -s http://localhost:8081/health | jq

Expected: {"ok": true, "uptimeSec": <n>, "db": "ok", "sessions": {}}.

Stop with Ctrl-C.

  • Step 5: Run bot inside Docker
scripts/dev.sh up
scripts/dev.sh logs

Expected: same logs, in container.

  • Step 6: Verify health from Docker
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:

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)
pnpm --filter @cmbot/bot test
  • Step 3: Implement apps/bot/src/audit.ts
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<string, unknown>;
};

export async function writeAuditLog(db: DB, input: AuditInput): Promise<void> {
  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)
pnpm --filter @cmbot/bot test
  • Step 5: Commit
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:

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<typeof vi.fn> };
}

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)
pnpm --filter @cmbot/bot test
  • Step 3: Implement apps/bot/src/telegram/middleware/whitelist.ts
import type { Context, MiddlewareFn } from "grammy";

export function makeWhitelistMiddleware(allowedUserIds: number[]): MiddlewareFn<Context> {
  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)
pnpm --filter @cmbot/bot test
  • Step 5: Implement apps/bot/src/telegram/middleware/audit.ts
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<Context> = 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
import type { Context } from "grammy";

export async function handleStart(ctx: Context): Promise<void> {
  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
import type { Context } from "grammy";

export async function handleHelp(ctx: Context): Promise<void> {
  await ctx.reply(
    "Available commands:\n\n" +
      "/start — show the welcome message\n" +
      "/help — show this help\n" +
      "/pair <label> — pair a new WhatsApp account\n" +
      "/unpair <label> — disconnect and forget a paired account\n" +
      "/accounts — list paired accounts and connection status\n" +
      "/groups <label> — list groups for a given account",
  );
}
  • Step 8: Implement apps/bot/src/telegram/bot.ts
import { Bot } from "grammy";
import { env } from "../env.js";
import { logger } from "../logger.js";
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js";
import { handleStart } from "./commands/start.js";
import { handleHelp } from "./commands/help.js";

export function createTelegramBot(): Bot {
  const bot = new Bot(env.TELEGRAM_BOT_TOKEN);

  bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
  bot.use(auditMiddleware);

  bot.command("start", handleStart);
  bot.command("help", handleHelp);

  bot.catch((err) => {
    logger.error({ err }, "telegram error");
  });

  return bot;
}
  • Step 9: Wire bot into apps/bot/src/index.ts

Replace the existing file:

import { logger } from "./logger.js";
import { pool } from "./db.js";
import { startHealthServer } from "./health.js";
import { createTelegramBot } from "./telegram/bot.js";

async function main(): Promise<void> {
  logger.info("bot starting");
  const health = startHealthServer();
  const tg = createTelegramBot();

  void tg.start({
    onStart: (info) => logger.info({ username: info.username }, "telegram polling started"),
    drop_pending_updates: true,
  });

  const shutdown = async (signal: string): Promise<void> => {
    logger.info({ signal }, "shutting down");
    await tg.stop();
    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 10: Run bot, send /start and /help via Telegram
scripts/dev.sh up
scripts/dev.sh logs

In Telegram:

  • Send /start → expect welcome message.
  • Send /help → expect command list.
  • Have a non-whitelisted account try /start → expect "Sorry, this bot is private."

Verify in Postgres:

psql "$DATABASE_URL" -c "SELECT action, payload FROM audit_log ORDER BY created_at DESC LIMIT 5;"

Expected: rows for tg.command.start and tg.command.help.

  • Step 11: Commit
git add apps/bot
git -c commit.gpgsign=false commit -m "feat(bot): add telegram bot with whitelist, /start, /help, audit"

Task 12: QR renderer (string → PNG buffer)

Files:

  • Create: apps/bot/src/whatsapp/qr-renderer.ts

  • Create: apps/bot/src/whatsapp/qr-renderer.test.ts

  • Step 1: Write the failing test

Create apps/bot/src/whatsapp/qr-renderer.test.ts:

import { describe, expect, it } from "vitest";
import { renderQrPng } from "./qr-renderer.js";

describe("renderQrPng", () => {
  it("returns a PNG buffer for a non-empty string", async () => {
    const png = await renderQrPng("test-qr-payload");
    expect(png).toBeInstanceOf(Buffer);
    // PNG magic bytes: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
    expect(png[0]).toBe(0x89);
    expect(png.subarray(1, 4).toString("ascii")).toBe("PNG");
  });

  it("rejects empty input", async () => {
    await expect(renderQrPng("")).rejects.toThrow();
  });
});
  • Step 2: Run test (expect failure)
pnpm --filter @cmbot/bot test
  • Step 3: Implement apps/bot/src/whatsapp/qr-renderer.ts
import QRCode from "qrcode";

export async function renderQrPng(payload: string): Promise<Buffer> {
  if (!payload) throw new Error("renderQrPng: payload is empty");
  return await QRCode.toBuffer(payload, { type: "png", width: 512, margin: 2 });
}
  • Step 4: Run test (expect pass)
pnpm --filter @cmbot/bot test
  • Step 5: Commit
git add apps/bot/src/whatsapp/qr-renderer.ts apps/bot/src/whatsapp/qr-renderer.test.ts
git -c commit.gpgsign=false commit -m "feat(bot): add QR PNG renderer"

Task 13: Single-account Baileys session wrapper

Files:

  • Create: apps/bot/src/whatsapp/session.ts

This task wires Baileys per account. We don't write a unit test against Baileys itself (its API is heavy to mock and the value is low) — testing happens in task 16 via the manual end-to-end checklist.

  • Step 1: Implement apps/bot/src/whatsapp/session.ts
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import {
  default as makeWASocket,
  useMultiFileAuthState,
  type WASocket,
  type ConnectionState,
  DisconnectReason,
  Browsers,
} from "@whiskeysockets/baileys";
import type { Boom } from "@hapi/boom";
import { logger } from "../logger.js";
import { env } from "../env.js";

export type SessionEvent =
  | { type: "qr"; payload: string }
  | { type: "open"; phoneNumber: string | undefined }
  | { type: "close"; reason: number; loggedOut: boolean }
  | { type: "error"; error: unknown };

export type SessionEventHandler = (event: SessionEvent) => void | Promise<void>;

export type Session = {
  accountId: string;
  socket: WASocket;
  close: () => Promise<void>;
};

export async function startSession(params: {
  accountId: string;
  onEvent: SessionEventHandler;
}): Promise<Session> {
  const { accountId, onEvent } = params;
  const sessionDir = join(env.SESSIONS_DIR, accountId);
  await mkdir(sessionDir, { recursive: true });

  const { state, saveCreds } = await useMultiFileAuthState(sessionDir);

  const socket = makeWASocket({
    auth: state,
    printQRInTerminal: false,
    browser: Browsers.macOS("Safari"),
    syncFullHistory: false,
    logger: logger.child({ accountId, component: "baileys" }) as never,
  });

  socket.ev.on("creds.update", () => void saveCreds());

  socket.ev.on("connection.update", (update: Partial<ConnectionState>) => {
    if (update.qr) {
      void onEvent({ type: "qr", payload: update.qr });
    }
    if (update.connection === "open") {
      const phoneNumber = socket.user?.id?.split(":")[0];
      void onEvent({ type: "open", phoneNumber });
    }
    if (update.connection === "close") {
      const reason =
        (update.lastDisconnect?.error as Boom | undefined)?.output?.statusCode ?? 0;
      const loggedOut = reason === DisconnectReason.loggedOut;
      void onEvent({ type: "close", reason, loggedOut });
    }
  });

  return {
    accountId,
    socket,
    close: async () => {
      try {
        socket.end(undefined);
      } catch (err) {
        logger.warn({ err, accountId }, "session.close: error closing socket");
      }
    },
  };
}
  • Step 2: Typecheck
pnpm --filter @cmbot/bot typecheck

Expected: no errors.

  • Step 3: Commit
git add apps/bot/src/whatsapp/session.ts
git -c commit.gpgsign=false commit -m "feat(bot): add Baileys session wrapper"

Task 14: Session manager (lifecycle, reconnect, state machine)

Files:

  • Create: apps/bot/src/whatsapp/session-manager.ts

  • Create: apps/bot/src/whatsapp/session-manager.test.ts

  • Step 1: Write the failing test for state transitions

Create apps/bot/src/whatsapp/session-manager.test.ts:

import { describe, expect, it } from "vitest";
import { reduceState, type SessionState } from "./session-manager.js";

describe("reduceState", () => {
  it("pending → connecting on start", () => {
    expect(reduceState("pending", { kind: "starting" })).toBe<SessionState>("connecting");
  });
  it("connecting → connected on open", () => {
    expect(reduceState("connecting", { kind: "open" })).toBe<SessionState>("connected");
  });
  it("connected → disconnected on close (not logged out)", () => {
    expect(reduceState("connected", { kind: "close", loggedOut: false })).toBe<SessionState>(
      "disconnected",
    );
  });
  it("any → logged_out on logged-out close", () => {
    expect(reduceState("connected", { kind: "close", loggedOut: true })).toBe<SessionState>(
      "logged_out",
    );
    expect(reduceState("connecting", { kind: "close", loggedOut: true })).toBe<SessionState>(
      "logged_out",
    );
  });
  it("ignores stray events that don't match transitions (returns same state)", () => {
    expect(reduceState("connected", { kind: "starting" })).toBe<SessionState>("connected");
  });
});
  • Step 2: Run test (expect failure)
pnpm --filter @cmbot/bot test
  • Step 3: Implement apps/bot/src/whatsapp/session-manager.ts
import { eq } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../db.js";
import { logger } from "../logger.js";
import { startSession, type Session, type SessionEvent } from "./session.js";

export type SessionState =
  | "pending"
  | "connecting"
  | "connected"
  | "disconnected"
  | "logged_out"
  | "banned";

export type StateEvent =
  | { kind: "starting" }
  | { kind: "open" }
  | { kind: "close"; loggedOut: boolean };

export function reduceState(current: SessionState, event: StateEvent): SessionState {
  if (event.kind === "starting" && current === "pending") return "connecting";
  if (event.kind === "open" && (current === "connecting" || current === "disconnected")) {
    return "connected";
  }
  if (event.kind === "close") {
    if (event.loggedOut) return "logged_out";
    return "disconnected";
  }
  return current;
}

export type SessionListener = (
  accountId: string,
  state: SessionState,
  event: SessionEvent,
) => void | Promise<void>;

class SessionManager {
  private sessions = new Map<string, Session>();
  private states = new Map<string, SessionState>();
  private listeners = new Set<SessionListener>();

  on(listener: SessionListener): () => void {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }

  getState(accountId: string): SessionState {
    return this.states.get(accountId) ?? "pending";
  }

  getCounts(): Record<SessionState, number> {
    const counts: Record<SessionState, number> = {
      pending: 0,
      connecting: 0,
      connected: 0,
      disconnected: 0,
      logged_out: 0,
      banned: 0,
    };
    for (const state of this.states.values()) counts[state]++;
    return counts;
  }

  hasSession(accountId: string): boolean {
    return this.sessions.has(accountId);
  }

  getSession(accountId: string): Session | undefined {
    return this.sessions.get(accountId);
  }

  async start(accountId: string): Promise<void> {
    if (this.sessions.has(accountId)) {
      logger.debug({ accountId }, "session-manager: already running, ignoring start");
      return;
    }
    this.transition(accountId, { kind: "starting" });

    const session = await startSession({
      accountId,
      onEvent: (event) => this.handleEvent(accountId, event),
    });
    this.sessions.set(accountId, session);
  }

  async stop(accountId: string): Promise<void> {
    const session = this.sessions.get(accountId);
    if (!session) return;
    await session.close();
    this.sessions.delete(accountId);
  }

  async stopAll(): Promise<void> {
    await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
  }

  /** Restart any account whose DB row says it should be connected. */
  async resumeFromDb(): Promise<void> {
    const rows = await db
      .select({ id: whatsappAccounts.id, status: whatsappAccounts.status })
      .from(whatsappAccounts);
    for (const row of rows) {
      if (row.status === "connected" || row.status === "disconnected") {
        try {
          await this.start(row.id);
        } catch (err) {
          logger.warn({ err, accountId: row.id }, "resumeFromDb: failed to start");
        }
      }
    }
  }

  private async handleEvent(accountId: string, event: SessionEvent): Promise<void> {
    if (event.type === "open") {
      this.transition(accountId, { kind: "open" });
      await db
        .update(whatsappAccounts)
        .set({
          status: "connected",
          phoneNumber: event.phoneNumber ?? null,
          lastConnectedAt: new Date(),
        })
        .where(eq(whatsappAccounts.id, accountId));
    } else if (event.type === "close") {
      this.transition(accountId, { kind: "close", loggedOut: event.loggedOut });
      await db
        .update(whatsappAccounts)
        .set({ status: event.loggedOut ? "logged_out" : "disconnected" })
        .where(eq(whatsappAccounts.id, accountId));

      if (!event.loggedOut) {
        // Auto-reconnect after 5s
        setTimeout(() => {
          void this.stop(accountId).then(() => this.start(accountId));
        }, 5000);
      } else {
        await this.stop(accountId);
      }
    } else if (event.type === "qr") {
      await db
        .update(whatsappAccounts)
        .set({ lastQrAt: new Date() })
        .where(eq(whatsappAccounts.id, accountId));
    }

    // Fan out to listeners (Telegram QR delivery, group sync trigger, etc.)
    for (const listener of this.listeners) {
      try {
        await listener(accountId, this.getState(accountId), event);
      } catch (err) {
        logger.warn({ err, accountId }, "session-manager: listener error");
      }
    }
  }

  private transition(accountId: string, event: StateEvent): void {
    const current = this.states.get(accountId) ?? "pending";
    const next = reduceState(current, event);
    if (current !== next) {
      logger.info({ accountId, from: current, to: next }, "session-manager: state change");
    }
    this.states.set(accountId, next);
  }
}

export const sessionManager = new SessionManager();
  • Step 4: Run test (expect pass)
pnpm --filter @cmbot/bot test
  • Step 5: Wire session counts into health and add session-manager startup to index.ts

Replace apps/bot/src/index.ts:

import { logger } from "./logger.js";
import { pool } from "./db.js";
import { startHealthServer, setSessionCountsProvider } from "./health.js";
import { createTelegramBot } from "./telegram/bot.js";
import { sessionManager } from "./whatsapp/session-manager.js";

async function main(): Promise<void> {
  logger.info("bot starting");
  const health = startHealthServer();
  setSessionCountsProvider(() => sessionManager.getCounts());

  const tg = createTelegramBot();
  void tg.start({
    onStart: (info) => logger.info({ username: info.username }, "telegram polling started"),
    drop_pending_updates: true,
  });

  await sessionManager.resumeFromDb();

  const shutdown = async (signal: string): Promise<void> => {
    logger.info({ signal }, "shutting down");
    await tg.stop();
    await sessionManager.stopAll();
    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 6: Typecheck
pnpm --filter @cmbot/bot typecheck
  • Step 7: Commit
git add apps/bot
git -c commit.gpgsign=false commit -m "feat(bot): add session manager with state machine + reconnect"

Task 15: Group sync (pull WA groups → upsert DB)

Files:

  • Create: apps/bot/src/whatsapp/group-sync.ts

  • Step 1: Implement apps/bot/src/whatsapp/group-sync.ts

import { sql } from "drizzle-orm";
import type { WASocket } from "@whiskeysockets/baileys";
import { whatsappGroups } from "@cmbot/db";
import { db } from "../db.js";
import { logger } from "../logger.js";

export async function syncGroupsForAccount(
  accountId: string,
  socket: WASocket,
): Promise<{ synced: number }> {
  const meta = await socket.groupFetchAllParticipating();
  const entries = Object.values(meta);

  if (entries.length === 0) {
    logger.info({ accountId }, "group-sync: no groups");
    return { synced: 0 };
  }

  const rows = entries.map((g) => ({
    accountId,
    waGroupJid: g.id,
    name: g.subject ?? "(no subject)",
    participantCount: g.participants?.length ?? 0,
    isArchived: false,
    lastSyncedAt: new Date(),
  }));

  await db
    .insert(whatsappGroups)
    .values(rows)
    .onConflictDoUpdate({
      target: [whatsappGroups.accountId, whatsappGroups.waGroupJid],
      set: {
        name: sql`excluded.name`,
        participantCount: sql`excluded.participant_count`,
        lastSyncedAt: sql`excluded.last_synced_at`,
      },
    });

  logger.info({ accountId, count: rows.length }, "group-sync: synced");
  return { synced: rows.length };
}
  • Step 2: Typecheck
pnpm --filter @cmbot/bot typecheck
  • Step 3: Commit
git add apps/bot/src/whatsapp/group-sync.ts
git -c commit.gpgsign=false commit -m "feat(bot): add group sync upsert"

Task 16: /pair, /unpair, /accounts, /groups commands

Files:

  • Create: apps/bot/src/telegram/commands/pair.ts

  • Create: apps/bot/src/telegram/commands/unpair.ts

  • Create: apps/bot/src/telegram/commands/accounts.ts

  • Create: apps/bot/src/telegram/commands/groups.ts

  • Modify: apps/bot/src/telegram/bot.ts

  • Step 1: Implement apps/bot/src/telegram/commands/pair.ts

import type { Context, InputFile } from "grammy";
import { InputFile as InputFileCtor } from "grammy";
import { eq, and } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../../db.js";
import { logger } from "../../logger.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
import { renderQrPng } from "../../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
import { writeAuditLog } from "../../audit.js";

const qrMessageIdByAccount = new Map<string, number>();

export async function handlePair(ctx: Context): Promise<void> {
  const text = ctx.message?.text ?? "";
  const label = text.replace(/^\/pair\s*/, "").trim().replace(/^["']|["']$/g, "");
  if (!label) {
    await ctx.reply('Usage: /pair "Account Label"');
    return;
  }

  const operatorId = ctx.from?.id;
  if (!operatorId) return;

  // Look up the operator row by Telegram ID. Seeded in dev; bot-time create otherwise.
  const operatorRow = await db.query.operators.findFirst({
    where: (o, { eq }) => eq(o.telegramUserId, operatorId),
  });
  if (!operatorRow) {
    await ctx.reply("Your Telegram ID is whitelisted but no operator row exists. Run `scripts/db.sh seed`.");
    return;
  }

  const existing = await db.query.whatsappAccounts.findFirst({
    where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
  });
  if (existing && existing.status === "connected") {
    await ctx.reply(`Account "${label}" is already connected. Use /unpair first.`);
    return;
  }

  let accountId = existing?.id;
  if (!accountId) {
    const [created] = await db
      .insert(whatsappAccounts)
      .values({ operatorId: operatorRow.id, label, status: "pending" })
      .returning({ id: whatsappAccounts.id });
    accountId = created!.id;
  }

  await ctx.reply(`📡 Starting pairing for "${label}". A QR code will arrive shortly.`);

  // Subscribe to events for this specific account
  const off = sessionManager.on(async (id, _state, event) => {
    if (id !== accountId) return;
    try {
      if (event.type === "qr") {
        const png = await renderQrPng(event.payload);
        const file: InputFile = new InputFileCtor(png, `pair-${id}.png`);
        const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`;
        const existingMsg = qrMessageIdByAccount.get(id);
        if (existingMsg) {
          await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, {
            type: "photo",
            media: file,
            caption,
          });
        } else {
          const sent = await ctx.replyWithPhoto(file, { caption });
          qrMessageIdByAccount.set(id, sent.message_id);
        }
      } else if (event.type === "open") {
        qrMessageIdByAccount.delete(id);
        await ctx.reply(
          `✅ "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`,
        );
        await writeAuditLog(db, {
          operatorId: operatorRow.id,
          source: "telegram",
          action: "account.paired",
          targetType: "whatsapp_account",
          targetId: id,
          payload: { label },
        });
        // Trigger group sync now that we're connected
        const session = sessionManager.getSession(id);
        if (session) {
          const result = await syncGroupsForAccount(id, session.socket);
          await ctx.reply(`Synced ${result.synced} groups. Ready to send reminders.`);
        }
        off();
      } else if (event.type === "close" && event.loggedOut) {
        qrMessageIdByAccount.delete(id);
        await ctx.reply(`⚠️ Pairing failed (logged out).`);
        off();
      }
    } catch (err) {
      logger.error({ err, accountId: id }, "pair handler error");
    }
  });

  try {
    await sessionManager.start(accountId);
  } catch (err) {
    logger.error({ err, accountId }, "pair: start failed");
    await ctx.reply(`Pairing failed to start: ${(err as Error).message}`);
    off();
  }
}
  • Step 2: Implement apps/bot/src/telegram/commands/unpair.ts
import type { Context } from "grammy";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { eq, and } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../../db.js";
import { env } from "../../env.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
import { writeAuditLog } from "../../audit.js";

export async function handleUnpair(ctx: Context): Promise<void> {
  const text = ctx.message?.text ?? "";
  const label = text.replace(/^\/unpair\s*/, "").trim().replace(/^["']|["']$/g, "");
  if (!label) {
    await ctx.reply('Usage: /unpair "Account Label"');
    return;
  }

  const operatorId = ctx.from?.id;
  if (!operatorId) return;

  const operatorRow = await db.query.operators.findFirst({
    where: (o, { eq }) => eq(o.telegramUserId, operatorId),
  });
  if (!operatorRow) return;

  const account = await db.query.whatsappAccounts.findFirst({
    where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
  });
  if (!account) {
    await ctx.reply(`No account labelled "${label}".`);
    return;
  }

  await sessionManager.stop(account.id);
  await rm(join(env.SESSIONS_DIR, account.id), { recursive: true, force: true });

  await db
    .update(whatsappAccounts)
    .set({ status: "logged_out", phoneNumber: null })
    .where(eq(whatsappAccounts.id, account.id));

  await writeAuditLog(db, {
    operatorId: operatorRow.id,
    source: "telegram",
    action: "account.unpaired",
    targetType: "whatsapp_account",
    targetId: account.id,
    payload: { label },
  });

  await ctx.reply(`🗑 "${label}" unpaired. Session files deleted.`);
}
  • Step 3: Implement apps/bot/src/telegram/commands/accounts.ts
import type { Context } from "grammy";
import { db } from "../../db.js";
import { sessionManager } from "../../whatsapp/session-manager.js";

export async function handleAccounts(ctx: Context): Promise<void> {
  const operatorId = ctx.from?.id;
  if (!operatorId) return;

  const operatorRow = await db.query.operators.findFirst({
    where: (o, { eq }) => eq(o.telegramUserId, operatorId),
  });
  if (!operatorRow) return;

  const accounts = await db.query.whatsappAccounts.findMany({
    where: (a, { eq }) => eq(a.operatorId, operatorRow.id),
    orderBy: (a, { asc }) => [asc(a.label)],
  });

  if (accounts.length === 0) {
    await ctx.reply("No accounts paired yet. Use /pair \"Label\" to add one.");
    return;
  }

  const lines = accounts.map((a) => {
    const live = sessionManager.getState(a.id);
    const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
    return `• ${a.label}${phone} — db:${a.status} live:${live}`;
  });
  await ctx.reply(`📒 Paired accounts:\n${lines.join("\n")}`);
}
  • Step 4: Implement apps/bot/src/telegram/commands/groups.ts
import type { Context } from "grammy";
import { db } from "../../db.js";

export async function handleGroups(ctx: Context): Promise<void> {
  const text = ctx.message?.text ?? "";
  const label = text.replace(/^\/groups\s*/, "").trim().replace(/^["']|["']$/g, "");
  if (!label) {
    await ctx.reply('Usage: /groups "Account Label"');
    return;
  }

  const operatorId = ctx.from?.id;
  if (!operatorId) return;

  const operatorRow = await db.query.operators.findFirst({
    where: (o, { eq }) => eq(o.telegramUserId, operatorId),
  });
  if (!operatorRow) return;

  const account = await db.query.whatsappAccounts.findFirst({
    where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
  });
  if (!account) {
    await ctx.reply(`No account labelled "${label}".`);
    return;
  }

  const groups = await db.query.whatsappGroups.findMany({
    where: (g, { eq }) => eq(g.accountId, account.id),
    orderBy: (g, { asc }) => [asc(g.name)],
  });

  if (groups.length === 0) {
    await ctx.reply(`No groups synced for "${label}" yet.`);
    return;
  }

  const lines = groups.slice(0, 50).map((g) => `• ${g.name} (${g.participantCount})`);
  const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : "";
  await ctx.reply(`👥 Groups in "${label}":\n${lines.join("\n")}${overflow}`);
}
  • Step 5: Wire commands into apps/bot/src/telegram/bot.ts

Replace the file:

import { Bot } from "grammy";
import { env } from "../env.js";
import { logger } from "../logger.js";
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js";
import { handleStart } from "./commands/start.js";
import { handleHelp } from "./commands/help.js";
import { handlePair } from "./commands/pair.js";
import { handleUnpair } from "./commands/unpair.js";
import { handleAccounts } from "./commands/accounts.js";
import { handleGroups } from "./commands/groups.js";

export function createTelegramBot(): Bot {
  const bot = new Bot(env.TELEGRAM_BOT_TOKEN);

  bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
  bot.use(auditMiddleware);

  bot.command("start", handleStart);
  bot.command("help", handleHelp);
  bot.command("pair", handlePair);
  bot.command("unpair", handleUnpair);
  bot.command("accounts", handleAccounts);
  bot.command("groups", handleGroups);

  bot.catch((err) => {
    logger.error({ err }, "telegram error");
  });

  return bot;
}
  • Step 6: Typecheck
pnpm --filter @cmbot/bot typecheck
  • Step 7: Commit
git add apps/bot
git -c commit.gpgsign=false commit -m "feat(bot): add /pair /unpair /accounts /groups commands"

Task 17: Manual end-to-end pairing test

Files:

  • Create: docs/superpowers/specs/manual-test-pairing.md

  • Step 1: Create the manual test runbook

# Manual test: WhatsApp pairing end-to-end

Run this checklist on every release that touches the pairing flow. It can't be
automated — pairing requires a real phone scanning a QR.

## Prerequisites
- `.env.development` filled in.
- `scripts/db.sh migrate && scripts/db.sh seed` ran clean.
- `scripts/dev.sh up` is running; `scripts/dev.sh logs` is tailing.
- Dev WhatsApp mock account installed on a test phone (NOT brother's prod accounts).
- Dev Telegram bot opened in Telegram.

## Steps

1. Send `/start` to the dev bot. Expected: welcome message.
2. Send `/help`. Expected: command list including `/pair`.
3. Send `/pair "Test Account 1"`. Expected:
   - Reply: "📡 Starting pairing for 'Test Account 1'..."
   - Within ~5 seconds, a QR PNG is sent.
4. On the test phone: WhatsApp → Settings → Linked Devices → Link a Device → scan the QR from Telegram.
5. Within ~5 seconds expect Telegram replies:
   - "✅ 'Test Account 1' connected as +60xxxxxxx" (your test phone number).
   - "Synced N groups. Ready to send reminders." (N = number of WA groups on the test phone).
6. Send `/accounts`. Expected: line "• Test Account 1 (+60xxx) — db:connected live:connected".
7. Send `/groups "Test Account 1"`. Expected: bulleted list of groups.
8. Verify in Postgres:
   ```sql
   SELECT label, status, phone_number FROM whatsapp_accounts;
   SELECT count(*) FROM whatsapp_groups;
   SELECT action, target_id FROM audit_log ORDER BY created_at DESC LIMIT 5;

Expected: account row connected; groups present; audit log shows account.paired. 9. Restart the bot: scripts/dev.sh down && scripts/dev.sh up. Expected: in logs, "session-manager: state change connecting → connected" for the test account, no QR re-prompt. 10. Send /unpair "Test Account 1". Expected: - Reply: "🗑 'Test Account 1' unpaired. Session files deleted." - whatsapp_accounts.status is logged_out. - Directory dev-data/sessions/<account-id>/ is gone.

Failure modes to verify

  • QR expiry: ignore the QR for 30s. Bot should edit the same Telegram message with a new QR (no second photo). Repeat 3-5 times to verify edits keep working.
  • Wrong-account /pair: as a non-whitelisted Telegram user, send /pair "X". Expected: "Sorry, this bot is private."
  • Re-pair while connected: send /pair "Test Account 1" again immediately after step 5. Expected: rejection "already connected. Use /unpair first."

Sign-off

  • All steps passed
  • Postgres rows match expectations
  • No errors in scripts/dev.sh logs
  • Tester: ____________ Date: ____________

- [ ] **Step 2: Run the manual test end-to-end against your dev mock account**

Follow each step in `docs/superpowers/specs/manual-test-pairing.md`. If any step fails, fix the relevant task before continuing.

- [ ] **Step 3: Commit**

```bash
git add docs/superpowers/specs/manual-test-pairing.md
git -c commit.gpgsign=false commit -m "docs: add manual end-to-end pairing test runbook"

Task 18: README and final commit

Files:

  • Create: README.md

  • Step 1: Create README.md

# cm WhatsApp Reminder Bot

Self-hosted WhatsApp reminder bot. Pairs multiple WhatsApp accounts via Telegram-delivered QR codes and sends scheduled reminders to groups.

## Status

**Plan 1 complete.** Foundation, DB schema, and Telegram-driven WhatsApp pairing are working end-to-end. Reminder scheduling, the web dashboard, and production deploy are upcoming plans (`docs/superpowers/plans/`).

## Quick start (dev)

```bash
# Prereqs: Node 22, pnpm 9, Docker, access to the home Postgres at 192.168.0.210

# 1. Configure env
cp envs/.env.example .env.development
# edit .env.development with real values
scripts/gen_auth_secret.sh --write

# 2. Apply migrations and seed
pnpm install
scripts/db.sh migrate
scripts/db.sh seed

# 3. Run the bot
scripts/dev.sh up
scripts/dev.sh logs

Open Telegram, message your dev bot /start, then /pair "Test".

Layout

  • apps/bot/ — Node service: Baileys WhatsApp + grammy Telegram + (later) pg-boss scheduler
  • apps/web/ — Next.js dashboard (plan 3)
  • packages/db/ — Drizzle schema and migrations
  • packages/shared/ — cross-app helpers
  • docs/superpowers/specs/ — design specs and manual test runbooks
  • docs/superpowers/plans/ — implementation plans

Scripts

  • scripts/dev.sh up|down|logs|status|build — local Docker stack lifecycle
  • scripts/db.sh migrate|generate|studio|seed|reset — Drizzle migration helper
  • scripts/gen_auth_secret.sh [--write] — generate AUTH_SECRET
  • scripts/publish.sh — push to Gitea registry (plan 4)
  • scripts/link-account.sh — CLI pairing for dev (plan 2)

Design

See docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md.


- [ ] **Step 2: Commit and push**

```bash
git add README.md
git -c commit.gpgsign=false commit -m "docs: add top-level README"
git push -u origin master

Expected: push succeeds to http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git.


Plan 1 done — what's working

After all 18 tasks:

  • Repo skeleton on Gitea with all dev tooling.
  • All 11 DB tables migrated to whatsapp_bot_dev on 192.168.0.210.
  • bot service runs in Docker with structured logging and health endpoint.
  • Telegram bot accepts /start, /help, /pair, /unpair, /accounts, /groups from whitelisted users only; every command is audited.
  • WhatsApp pairing: /pair "label" → QR delivered to Telegram → scan → connected → groups synced → confirmation back. Auto-reconnect on disconnect; logout detected; restart-survival via useMultiFileAuthState.
  • Manual test runbook documents the verification steps.

Next plan

docs/superpowers/plans/<next-date>-reminder-scheduling.md — adds pg-boss, reminder CRUD via Telegram, fire-reminder handler, sender (text/image/video), retry policy, run history. End state: brother can create one-off and recurring reminders by chatting with the bot, and they fire on time to the right groups.