From 2122182c56c9c26ed1383e191fb6f71ab50322d5 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 15:05:49 +0800 Subject: [PATCH] docs(plan): refactor plan 1 to Docker-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize plan 1 so a long-lived `tools` container running Node 22 + pnpm is the entry point for every install/test/typecheck/migration command. The host only needs Docker — no Node or pnpm install required. Tasks reordered so the tools container exists before any pnpm operation; new tasks added for the bootstrap install and env-file population. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-03-foundation-and-pairing.md | 1387 ++++++++++------- 1 file changed, 825 insertions(+), 562 deletions(-) diff --git a/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md b/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md index bb534ce..a22cc82 100644 --- a/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md +++ b/docs/superpowers/plans/2026-05-03-foundation-and-pairing.md @@ -4,13 +4,14 @@ **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. +**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`. **Everything runs in Docker — there is no host requirement beyond Docker itself.** A long-running `tools` container holds Node 22 + pnpm and is the entry point for every install/test/typecheck/migration command. 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. +**Tech Stack:** TypeScript, Node 22 (in containers only), pnpm 9 (in containers only), 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.) +- Docker reachable on the dev machine (sudo or docker group). +- Postgres at `192.168.0.210` reachable from this machine *and* from the Docker bridge (`172.16.0.0/12`). `pg_hba.conf` permits both. +- Two databases exist on that Postgres instance: `whatsapp_bot_dev` and `whatsapp_bot_prod`. Two roles with passwords. (`whatsapp_bot_prod` is 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). @@ -24,11 +25,11 @@ ``` cm_whatsapp_bot_v1/ ├── .gitignore -├── .nvmrc Node 22 -├── package.json root (workspace root) +├── .nvmrc Node 22 (informational only) +├── package.json workspace root ├── pnpm-workspace.yaml ├── turbo.json -├── tsconfig.base.json shared TS config +├── tsconfig.base.json ├── README.md │ ├── apps/ @@ -39,14 +40,17 @@ cm_whatsapp_bot_v1/ │ └── src/ │ ├── index.ts bootstrap, graceful shutdown │ ├── env.ts zod env validation -│ ├── logger.ts pino instance -│ ├── db.ts drizzle client (re-export from packages/db) +│ ├── env.test.ts +│ ├── logger.ts +│ ├── db.ts drizzle client │ ├── health.ts internal HTTP server -│ ├── audit.ts audit_log writer +│ ├── audit.ts +│ ├── audit.test.ts │ ├── telegram/ -│ │ ├── bot.ts grammy instance +│ │ ├── bot.ts │ │ ├── middleware/ │ │ │ ├── whitelist.ts +│ │ │ ├── whitelist.test.ts │ │ │ └── audit.ts │ │ └── commands/ │ │ ├── start.ts @@ -57,8 +61,10 @@ cm_whatsapp_bot_v1/ │ │ └── groups.ts │ └── whatsapp/ │ ├── session-manager.ts +│ ├── session-manager.test.ts │ ├── session.ts │ ├── qr-renderer.ts +│ ├── qr-renderer.test.ts │ └── group-sync.ts │ ├── packages/ @@ -67,20 +73,24 @@ cm_whatsapp_bot_v1/ │ │ ├── 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 +│ │ │ ├── index.ts +│ │ │ ├── schema.ts +│ │ │ ├── migrate.ts +│ │ │ └── seed.ts +│ │ └── migrations/ │ └── shared/ │ ├── package.json │ ├── tsconfig.json +│ ├── vitest.config.ts │ └── src/ │ ├── index.ts -│ ├── rrule.ts parse/validate/next helpers -│ ├── media-paths.ts deterministic /data/media paths -│ └── timezones.ts IANA validation helper +│ ├── rrule.ts +│ ├── rrule.test.ts +│ ├── media-paths.ts +│ └── timezones.ts │ ├── docker/ +│ ├── tools.Dockerfile Node 22 + pnpm sidecar (long-lived) │ ├── bot.Dockerfile │ └── web.Dockerfile placeholder for plan 3 │ @@ -89,27 +99,30 @@ cm_whatsapp_bot_v1/ │ ├── envs/ │ └── .env.example -├── .env.development not committed-to-public; in this repo private OK +├── .env.development │ ├── scripts/ -│ ├── dev.sh +│ ├── dev.sh up | down | logs | status | exec | pnpm | shell │ ├── db.sh │ ├── gen_auth_secret.sh -│ ├── link-account.sh (stub now; populated in plan 2) -│ └── publish.sh (stub; populated in plan 4) +│ ├── link-account.sh stub for plan 2 +│ └── publish.sh stub for plan 4 │ └── docs/ └── superpowers/ + ├── plans/ + │ └── 2026-05-03-foundation-and-pairing.md └── specs/ - └── manual-test-pairing.md manual test runbook + ├── 2026-05-03-whatsapp-bot-design.md + └── manual-test-pairing.md ``` --- -## Task 1: Initialize git remote and gitignore +## Task 1: Git remote and .gitignore **Files:** -- Create: `/home/yiekheng/projects/cm_whatsapp_bot_v1/.gitignore` +- Create: `.gitignore` - [ ] **Step 1: Add the Gitea remote** @@ -119,7 +132,7 @@ 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)`. +Expected: prints `origin http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git (fetch)` and `(push)`. - [ ] **Step 2: Create `.gitignore`** @@ -168,7 +181,7 @@ git -c commit.gpgsign=false commit -m "chore: add .gitignore and configure remot --- -## Task 2: Root workspace, Turbo, and TS config +## Task 2: Root workspace files (no install yet) **Files:** - Create: `package.json` @@ -177,7 +190,9 @@ git -c commit.gpgsign=false commit -m "chore: add .gitignore and configure remot - Create: `tsconfig.base.json` - Create: `.nvmrc` -- [ ] **Step 1: Create `.nvmrc`** +These files only define the workspace shape; `pnpm install` is not run until the tools container exists in Task 6. + +- [ ] **Step 1: Create `.nvmrc`** (informational; the tools container is the source of truth) ``` 22 @@ -207,10 +222,7 @@ packages: "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" + "typecheck": "turbo run typecheck" }, "devDependencies": { "turbo": "^2.1.0", @@ -270,96 +282,411 @@ packages: } ``` -- [ ] **Step 6: Install root deps** +- [ ] **Step 6: Commit** ```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" +git add package.json pnpm-workspace.yaml turbo.json tsconfig.base.json .nvmrc +git -c commit.gpgsign=false commit -m "chore: initialize pnpm workspace + Turbo config" ``` --- -## Task 3: Create `packages/shared` with rrule + path helpers +## Task 3: Tools image and minimal compose + +**Files:** +- Create: `docker/tools.Dockerfile` +- Create: `docker-compose.base.yml` +- Create: `docker-compose.dev.yml` + +- [ ] **Step 1: Create `docker/tools.Dockerfile`** + +```dockerfile +FROM node:22-alpine + +# Lightweight build deps for any future native modules (qrcode is pure JS; +# baileys uses precompiled libsignal). git is needed by some pnpm workflows. +RUN apk add --no-cache git python3 make g++ \ + && corepack enable \ + && corepack prepare pnpm@9.12.0 --activate + +WORKDIR /workspace +ENV PNPM_HOME=/workspace/.pnpm-store +CMD ["tail", "-f", "/dev/null"] +``` + +- [ ] **Step 2: Create `docker-compose.base.yml`** + +```yaml +services: + tools: + build: + context: . + dockerfile: docker/tools.Dockerfile + image: cm-whatsapp-tools:dev + container_name: cmbot-tools + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + working_dir: /workspace + command: ["tail", "-f", "/dev/null"] + volumes: + - .:/workspace + environment: + HOME: /tmp + PNPM_HOME: /workspace/.pnpm-store + DATABASE_URL: ${DATABASE_URL} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-unset} + TELEGRAM_OPERATOR_WHITELIST: ${TELEGRAM_OPERATOR_WHITELIST:-0} + TELEGRAM_QR_CHAT_ID: ${TELEGRAM_QR_CHAT_ID:-0} + DATA_DIR: ${DATA_DIR:-/data} + SESSIONS_DIR: ${SESSIONS_DIR:-/data/sessions} + MEDIA_DIR: ${MEDIA_DIR:-/data/media} + BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081} + BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info} + SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0} + SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator} + networks: + - cmbot + +networks: + cmbot: + driver: bridge +``` + +- [ ] **Step 3: Create `docker-compose.dev.yml`** (initially with only the tools service; bot is added in Task 12) + +```yaml +services: + tools: + image: cm-whatsapp-tools:dev + build: + context: . + dockerfile: docker/tools.Dockerfile +``` + +- [ ] **Step 4: Commit** + +```bash +git add docker/tools.Dockerfile docker-compose.base.yml docker-compose.dev.yml +git -c commit.gpgsign=false commit -m "chore: add tools container + base/dev compose" +``` + +--- + +## Task 4: scripts/dev.sh with exec / pnpm subcommands + +**Files:** +- Create: `scripts/dev.sh` + +- [ ] **Step 1: Create `scripts/dev.sh`** + +```bash +#!/usr/bin/env bash +# Lifecycle for the local dev stack. All language tooling (pnpm, tests, +# typecheck, drizzle-kit) runs in the long-running `tools` container. +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 [service] Tail logs (all services, or one). + scripts/dev.sh status Print 'OK' if tools is running, else exit 1. + scripts/dev.sh build [service] Build images without starting. + scripts/dev.sh exec Run a command inside the tools container. + scripts/dev.sh pnpm Shortcut for: exec pnpm + scripts/dev.sh shell Open an interactive shell in tools. + scripts/dev.sh restart-bot Recreate the bot container only. + +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}" + +# Export host UID/GID so the tools container can write files owned by you. +export HOST_UID="$(id -u)" +export HOST_GID="$(id -g)" + +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 + +cmd="${1}" +shift || true + +case "${cmd}" in + up) + "${COMPOSE[@]}" up -d --build + "${COMPOSE[@]}" ps + ;; + down) + "${COMPOSE[@]}" down --remove-orphans + ;; + logs) + if [[ $# -ge 1 ]]; then + "${COMPOSE[@]}" logs -f "$1" + else + "${COMPOSE[@]}" logs -f + fi + ;; + status) + if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^tools$'; then + echo OK + else + echo "ERROR: tools container not running. Run 'scripts/dev.sh up' first." >&2 + exit 1 + fi + ;; + build) + "${COMPOSE[@]}" build "$@" + ;; + exec) + if [[ $# -lt 1 ]]; then + echo "ERROR: exec requires a command" >&2 + exit 2 + fi + "${COMPOSE[@]}" exec -T tools "$@" + ;; + pnpm) + "${COMPOSE[@]}" exec -T tools pnpm "$@" + ;; + shell) + "${COMPOSE[@]}" exec tools sh + ;; + restart-bot) + "${COMPOSE[@]}" up -d --force-recreate --no-deps bot + ;; + *) + echo "unknown command: ${cmd}" >&2 + usage >&2 + exit 1 + ;; +esac +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x scripts/dev.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add scripts/dev.sh +git -c commit.gpgsign=false commit -m "feat(scripts): add dev.sh with exec/pnpm/shell subcommands" +``` + +--- + +## Task 5: scripts/gen_auth_secret.sh and minimal .env.development for bootstrap + +**Files:** +- Create: `scripts/gen_auth_secret.sh` +- Create: `envs/.env.example` +- Create: `.env.development` (with bootstrap-only values; full values come in Task 9) + +- [ ] **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 `envs/.env.example`** + +```bash +# === Postgres === +DATABASE_URL=postgres://USER:PASS@192.168.0.210:5432/whatsapp_bot_dev + +# === Telegram === +TELEGRAM_BOT_TOKEN= +TELEGRAM_OPERATOR_WHITELIST= +TELEGRAM_QR_CHAT_ID= + +# === App data paths (inside containers) === +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 (placeholder for plan 3) === +WEB_PORT=3000 +AUTH_SECRET= +``` + +- [ ] **Step 3: Create initial `.env.development` (just enough to bring up tools)** + +```bash +DATABASE_URL=postgres://placeholder:placeholder@192.168.0.210:5432/whatsapp_bot_dev +TELEGRAM_BOT_TOKEN=placeholder +TELEGRAM_OPERATOR_WHITELIST=0 +TELEGRAM_QR_CHAT_ID=0 +DATA_DIR=/data +SESSIONS_DIR=/data/sessions +MEDIA_DIR=/data/media +BOT_HEALTH_PORT=8081 +BOT_LOG_LEVEL=debug +SEED_OPERATOR_TELEGRAM_ID=0 +SEED_OPERATOR_NAME=Dev +WEB_PORT=3000 +AUTH_SECRET= +``` + +This file will be filled with real values in Task 9. The placeholder values let the tools container come up so we can run `pnpm install`. + +- [ ] **Step 4: Make script executable and generate the AUTH_SECRET** + +```bash +chmod +x scripts/gen_auth_secret.sh +scripts/gen_auth_secret.sh --write +``` + +- [ ] **Step 5: Commit** + +```bash +git add scripts/gen_auth_secret.sh envs/.env.example .env.development +git -c commit.gpgsign=false commit -m "chore: add gen_auth_secret + bootstrap env files" +``` + +--- + +## Task 6: Bootstrap — start tools and run pnpm install + +This task is verification only — it produces `pnpm-lock.yaml` and `node_modules/` via the tools container, no source code changes. + +- [ ] **Step 1: Build and start the tools container** + +```bash +scripts/dev.sh up +``` + +Expected: builds `cm-whatsapp-tools:dev` (first run pulls node:22-alpine, installs build deps, activates pnpm), then `tools` shows as running. `scripts/dev.sh status` prints `OK`. + +- [ ] **Step 2: Verify pnpm version inside the container** + +```bash +scripts/dev.sh pnpm --version +``` + +Expected: `9.12.0` (or close — corepack chose this). + +- [ ] **Step 3: Run pnpm install** + +```bash +scripts/dev.sh pnpm install +``` + +Expected: pnpm completes without errors. Files appear on the host (because of the bind-mount): `pnpm-lock.yaml` at the repo root, `node_modules/` at the root, plus the per-workspace `node_modules/` once we add packages in later tasks. + +- [ ] **Step 4: Verify file ownership on host** + +```bash +ls -la pnpm-lock.yaml node_modules +``` + +Expected: files are owned by your host user, not root. (If they're root, your `HOST_UID/HOST_GID` aren't being passed through — fix `scripts/dev.sh` and reinstall.) + +- [ ] **Step 5: Commit lockfile** + +```bash +git add pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "chore: bootstrap pnpm install via tools container" +``` + +--- + +## Task 7: packages/shared with rrule, paths, timezone helpers (TDD) **Files:** - Create: `packages/shared/package.json` - Create: `packages/shared/tsconfig.json` +- Create: `packages/shared/vitest.config.ts` - Create: `packages/shared/src/index.ts` - Create: `packages/shared/src/rrule.ts` +- Create: `packages/shared/src/rrule.test.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`** +- [ ] **Step 1: Create `packages/shared/package.json`** ```json { @@ -391,7 +718,7 @@ Expected: fails with module-not-found errors. } ``` -- [ ] **Step 4: Create `packages/shared/tsconfig.json`** +- [ ] **Step 2: Create `packages/shared/tsconfig.json`** ```json { @@ -404,7 +731,7 @@ Expected: fails with module-not-found errors. } ``` -- [ ] **Step 5: Create `packages/shared/vitest.config.ts`** +- [ ] **Step 3: Create `packages/shared/vitest.config.ts`** ```typescript import { defineConfig } from "vitest/config"; @@ -417,7 +744,73 @@ export default defineConfig({ }); ``` -- [ ] **Step 6: Implement `packages/shared/src/rrule.ts`** +- [ ] **Step 4: 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 = "DTSTART:20200101T000000Z\nRRULE:FREQ=DAILY;COUNT=1"; + 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 5: Install workspace deps** + +```bash +scripts/dev.sh pnpm install +``` + +- [ ] **Step 6: Run the failing test** + +```bash +scripts/dev.sh pnpm --filter @cmbot/shared test +``` + +Expected: fails (modules not found). + +- [ ] **Step 7: Implement `packages/shared/src/rrule.ts`** ```typescript import { RRule, rrulestr } from "rrule"; @@ -435,8 +828,6 @@ export function parseRRule(rule: string): RRule { 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; @@ -446,10 +837,9 @@ 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 + if (!first) return { ok: true }; const second = parsed.after(first, false); if (!second) return { ok: true }; const gap = second.getTime() - first.getTime(); @@ -463,7 +853,7 @@ export function validateMinInterval(rule: string, timezone: string): IntervalChe } ``` -- [ ] **Step 7: Implement `packages/shared/src/media-paths.ts`** +- [ ] **Step 8: Implement `packages/shared/src/media-paths.ts`** ```typescript import { randomUUID } from "node:crypto"; @@ -483,19 +873,19 @@ export function absoluteMediaPath(storagePath: string, root: string = MEDIA_ROOT } ``` -- [ ] **Step 8: Implement `packages/shared/src/timezones.ts`** +- [ ] **Step 9: Implement `packages/shared/src/timezones.ts`** ```typescript import { DateTime } from "luxon"; +export const DEFAULT_TIMEZONE = "Asia/Kuala_Lumpur"; + 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`** +- [ ] **Step 10: Implement `packages/shared/src/index.ts`** ```typescript export * from "./rrule.js"; @@ -503,32 +893,32 @@ export * from "./media-paths.js"; export * from "./timezones.js"; ``` -- [ ] **Step 10: Install workspace deps and run tests** +- [ ] **Step 11: Run tests (expect pass)** ```bash -pnpm install -pnpm --filter @cmbot/shared test +scripts/dev.sh pnpm --filter @cmbot/shared test ``` -Expected: tests pass. +Expected: 7 tests pass. -- [ ] **Step 11: Commit** +- [ ] **Step 12: Commit** ```bash -git add packages/shared package.json pnpm-lock.yaml +git add packages/shared 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 +## Task 8: 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/index.ts` +- Create: `packages/db/src/migrate.ts` - Create: `packages/db/src/seed.ts` - [ ] **Step 1: Create `packages/db/package.json`** @@ -552,7 +942,7 @@ git -c commit.gpgsign=false commit -m "feat(shared): add rrule, media-path, time "typecheck": "tsc -p tsconfig.json --noEmit", "generate": "drizzle-kit generate", "migrate": "tsx src/migrate.ts", - "studio": "drizzle-kit studio", + "studio": "drizzle-kit studio --host 0.0.0.0", "seed": "tsx src/seed.ts" }, "dependencies": { @@ -601,7 +991,7 @@ export default defineConfig({ }); ``` -- [ ] **Step 4: Implement `packages/db/src/schema.ts`** (all tables from spec section 9) +- [ ] **Step 4: Create `packages/db/src/schema.ts`** (all 11 tables from spec §9) ```typescript import { @@ -759,7 +1149,6 @@ export const authSessions = pgTable("auth_sessions", { 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; @@ -773,7 +1162,7 @@ export type NewAuditLogEntry = typeof auditLog.$inferInsert; - [ ] **Step 5: Implement `packages/db/src/index.ts`** ```typescript -import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import * as schema from "./schema.js"; @@ -820,7 +1209,7 @@ if (!databaseUrl) { console.error("DATABASE_URL not set"); process.exit(1); } -if (!operatorTelegramId) { +if (!operatorTelegramId || operatorTelegramId === "0") { console.error("SEED_OPERATOR_TELEGRAM_ID not set"); process.exit(1); } @@ -841,14 +1230,14 @@ console.log(`Seeded operator with telegram_user_id=${operatorTelegramId}`); await pool.end(); ``` -- [ ] **Step 8: Generate the initial migration** +- [ ] **Step 8: Install deps and generate the initial migration** ```bash -pnpm install -DATABASE_URL=postgres://placeholder pnpm --filter @cmbot/db generate +scripts/dev.sh pnpm install +scripts/dev.sh pnpm --filter @cmbot/db generate ``` -Expected: `packages/db/migrations/0000_*.sql` is generated. +Expected: a single `0000_*.sql` file appears under `packages/db/migrations/`. - [ ] **Step 9: Commit** @@ -859,228 +1248,66 @@ git -c commit.gpgsign=false commit -m "feat(db): add drizzle schema for all tabl --- -## Task 5: Create `.env.example` and dev env file +## Task 9: Fill in the real `.env.development` values -**Files:** -- Create: `envs/.env.example` -- Create: `.env.development` +The file already exists from Task 5 with placeholders. Now substitute real values for the dev environment. -- [ ] **Step 1: Create `envs/.env.example`** +- [ ] **Step 1: Edit `.env.development`** with the real dev values you provisioned in the pre-flight checks + +Example (replace with your actuals): ```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 +DATABASE_URL=postgres://wabotdev:YOUR_DEV_PASSWORD@192.168.0.210:5432/whatsapp_bot_dev +TELEGRAM_BOT_TOKEN=1234567890:YOUR_DEV_BOT_TOKEN_FROM_BOTFATHER 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 +AUTH_SECRET= ``` -- [ ] **Step 3: Commit `.env.example` only (you'll fill `.env.development` after generating an auth secret in task 6)** +- [ ] **Step 2: Restart tools so the new env values are picked up** ```bash -git add envs/.env.example -git -c commit.gpgsign=false commit -m "chore: add .env.example documenting all keys" +scripts/dev.sh down +scripts/dev.sh up +``` + +- [ ] **Step 3: Verify env propagation** + +```bash +scripts/dev.sh exec sh -c 'echo "DB=$DATABASE_URL"; echo "TG=$TELEGRAM_BOT_TOKEN" | head -c 30' +``` + +Expected: prints your real DATABASE_URL and the first 30 chars of the TG token. + +- [ ] **Step 4: Commit the populated env file** + +```bash +git add .env.development +git -c commit.gpgsign=false commit -m "chore: populate .env.development with real dev values" ``` --- -## Task 6: Create scripts directory (dev.sh, db.sh, gen_auth_secret.sh, stub publish.sh) +## Task 10: scripts/db.sh wrapping pnpm via tools **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) +- Create: `scripts/link-account.sh` (stub for plan 2) +- Create: `scripts/publish.sh` (stub for plan 4) -- [ ] **Step 1: Create `scripts/gen_auth_secret.sh`** +- [ ] **Step 1: Create `scripts/db.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 ). +# Drizzle migration helper. Always runs inside the tools container. set -euo pipefail usage() { @@ -1090,11 +1317,9 @@ 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 studio Open drizzle-kit studio (port 4983 in container, exposed on host). 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'). + scripts/db.sh reset Drop and recreate ALL tables (dev only). Environment: ENV_FILE Override env file (default: .env.development). @@ -1110,6 +1335,7 @@ if [[ ! -f "${ENV_FILE}" ]]; then exit 2 fi +# Load env locally just to read DATABASE_URL for the prod-DB safety check. set -a # shellcheck disable=SC1090 source "${ENV_FILE}" @@ -1117,10 +1343,18 @@ 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 ;; + migrate) + exec scripts/dev.sh pnpm --filter @cmbot/db migrate + ;; + generate) + exec scripts/dev.sh pnpm --filter @cmbot/db generate + ;; + studio) + exec scripts/dev.sh pnpm --filter @cmbot/db studio + ;; + seed) + exec scripts/dev.sh pnpm --filter @cmbot/db seed + ;; reset) if [[ "${DATABASE_URL}" == *whatsapp_bot_prod* ]]; then echo "ERROR: refusing to reset prod database." >&2 @@ -1128,69 +1362,102 @@ case "${1:-}" in 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 " + scripts/dev.sh exec sh -c "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.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 + \"" + exec scripts/dev.sh 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)** +- [ ] **Step 2: Create `scripts/link-account.sh` (stub for plan 2)** ```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** +- [ ] **Step 3: Create `scripts/publish.sh` (stub for plan 4)** + +```bash +#!/usr/bin/env bash +echo "scripts/publish.sh: not yet implemented (see plan 4)" >&2 +exit 1 +``` + +- [ ] **Step 4: Make all scripts executable** ```bash chmod +x scripts/*.sh ``` -- [ ] **Step 7: Generate AUTH_SECRET into `.env.development` and commit env files** +- [ ] **Step 5: Commit** ```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" +git add scripts/ +git -c commit.gpgsign=false commit -m "feat(scripts): add db.sh wrapper and stubs for plans 2/4" ``` --- -## Task 7: Add bot Dockerfile and docker-compose files +## Task 11: Apply migrations and seed (verification step) + +This task has no source changes. It verifies that DB connectivity works end-to-end through the tools container. + +- [ ] **Step 1: Apply migrations** + +```bash +scripts/db.sh migrate +``` + +Expected: "Migrations applied." If you get a connection error, check `DATABASE_URL` and `pg_hba.conf` on `192.168.0.210`. + +- [ ] **Step 2: Seed the operator row** + +```bash +scripts/db.sh seed +``` + +Expected: "Seeded operator with telegram_user_id=YOUR_ID". + +- [ ] **Step 3: Verify rows exist** + +```bash +scripts/dev.sh exec sh -c 'pnpm exec tsx -e " + import { Pool } from \"pg\"; + const p = new Pool({ connectionString: process.env.DATABASE_URL }); + const r = await p.query(\"SELECT count(*) FROM operators\"); + console.log(\"operators rows:\", r.rows[0].count); + await p.end(); +"' +``` + +Expected: `operators rows: 1`. + +(No commit — this task is verification only.) + +--- + +## Task 12: Bot Dockerfile and add bot service to compose **Files:** - Create: `docker/bot.Dockerfile` - Create: `docker/web.Dockerfile` (placeholder) -- Create: `docker-compose.base.yml` -- Create: `docker-compose.dev.yml` +- Modify: `docker-compose.dev.yml` - [ ] **Step 1: Create `docker/bot.Dockerfile`** ```dockerfile FROM node:22-alpine AS base -RUN corepack enable +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate WORKDIR /app FROM base AS deps @@ -1214,9 +1481,7 @@ RUN pnpm --filter @cmbot/shared build && pnpm --filter @cmbot/db build && pnpm - 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/apps/bot /app/apps/bot COPY --from=build /app/packages/db /app/packages/db COPY --from=build /app/packages/shared /app/packages/shared EXPOSE 8081 @@ -1231,17 +1496,34 @@ WORKDIR /app CMD ["echo", "web service: not yet implemented (see plan 3)"] ``` -- [ ] **Step 3: Create `docker-compose.base.yml`** +- [ ] **Step 3: Replace `docker-compose.dev.yml`** with the bot service added ```yaml services: + tools: + image: cm-whatsapp-tools:dev + build: + context: . + dockerfile: docker/tools.Dockerfile + bot: build: context: . dockerfile: docker/bot.Dockerfile - image: cm-whatsapp-bot:local + target: build + image: cm-whatsapp-bot:dev + container_name: cmbot-bot + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + working_dir: /app + command: ["pnpm", "--filter", "@cmbot/bot", "dev"] restart: unless-stopped + volumes: + - .:/app + - ./dev-data:/data + ports: + - "127.0.0.1:8081:8081" environment: + NODE_ENV: development DATABASE_URL: ${DATABASE_URL} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} TELEGRAM_OPERATOR_WHITELIST: ${TELEGRAM_OPERATOR_WHITELIST} @@ -1253,55 +1535,37 @@ services: BOT_LOG_LEVEL: ${BOT_LOG_LEVEL} networks: - cmbot - -networks: - cmbot: - driver: bridge + depends_on: + - tools ``` -- [ ] **Step 4: Create `docker-compose.dev.yml`** +- [ ] **Step 4: Pre-create the dev-data directory so bind mount has correct ownership** -```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 +```bash +mkdir -p dev-data/sessions dev-data/media ``` - [ ] **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" +git add docker/ docker-compose.dev.yml +git -c commit.gpgsign=false commit -m "chore: add bot Dockerfile and bot service in dev compose" ``` --- -## Task 8: Bot service skeleton (env, logger, db client, health, shutdown) +## Task 13: apps/bot skeleton (env, logger, db, health, index) — TDD on env **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/env.test.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`** @@ -1409,16 +1673,21 @@ describe("parseEnv", () => { }); ``` -- [ ] **Step 5: Run test (expect failure — module missing)** +- [ ] **Step 5: Install workspace deps** ```bash -pnpm install -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm install ``` -Expected: fails (module not found). +- [ ] **Step 6: Run failing test** -- [ ] **Step 6: Implement `apps/bot/src/env.ts`** +```bash +scripts/dev.sh pnpm --filter @cmbot/bot test +``` + +Expected: fails with module not found. + +- [ ] **Step 7: Implement `apps/bot/src/env.ts`** ```typescript import { z } from "zod"; @@ -1450,15 +1719,15 @@ export function parseEnv(input: Record): Env { export const env = parseEnv(process.env); ``` -- [ ] **Step 7: Run test (expect pass)** +- [ ] **Step 8: Run test (expect pass)** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` Expected: 4 tests pass. -- [ ] **Step 8: Implement `apps/bot/src/logger.ts`** +- [ ] **Step 9: Implement `apps/bot/src/logger.ts`** ```typescript import pino from "pino"; @@ -1472,11 +1741,10 @@ export const logger = pino({ }); ``` -- [ ] **Step 9: Implement `apps/bot/src/db.ts`** +- [ ] **Step 10: 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); @@ -1485,7 +1753,7 @@ export { db, pool }; export type { DB }; ``` -- [ ] **Step 10: Implement `apps/bot/src/health.ts`** +- [ ] **Step 11: Implement `apps/bot/src/health.ts`** ```typescript import { createServer, type Server } from "node:http"; @@ -1501,7 +1769,7 @@ export type HealthStatus = { sessions?: Record; }; -let started = Date.now(); +const started = Date.now(); let getSessionCounts: () => Record = () => ({}); export function setSessionCountsProvider(fn: () => Record): void { @@ -1541,7 +1809,7 @@ export function startHealthServer(): Server { } ``` -- [ ] **Step 11: Implement `apps/bot/src/index.ts`** +- [ ] **Step 12: Implement `apps/bot/src/index.ts`** ```typescript import { logger } from "./logger.js"; @@ -1571,15 +1839,15 @@ main().catch((err) => { }); ``` -- [ ] **Step 12: Run typecheck** +- [ ] **Step 13: Typecheck** ```bash -pnpm --filter @cmbot/bot typecheck +scripts/dev.sh pnpm --filter @cmbot/bot typecheck ``` Expected: no errors. -- [ ] **Step 13: Commit** +- [ ] **Step 14: Commit** ```bash git add apps/bot pnpm-lock.yaml @@ -1588,78 +1856,51 @@ git -c commit.gpgsign=false commit -m "feat(bot): scaffold env, logger, db, heal --- -## Task 9: Apply migrations to dev DB and verify connectivity +## Task 14: Verify bot service runs and health is green -- [ ] **Step 1: Apply migrations** +This task has no source changes — it verifies the bot wires up correctly. -```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** +- [ ] **Step 1: Bring up the bot service** ```bash scripts/dev.sh up -scripts/dev.sh logs +scripts/dev.sh logs bot ``` -Expected: same logs, in container. +Expected log lines: +``` +{"msg":"bot starting"} +{"port":8081,"msg":"health server listening"} +{"msg":"bot ready"} +``` -- [ ] **Step 6: Verify health from Docker** +- [ ] **Step 2: Verify health from host** ```bash -curl -s http://localhost:8081/health | jq +curl -s http://localhost:8081/health | tee /dev/null ``` -Expected: same JSON. Then `scripts/dev.sh down`. +Expected: `{"ok":true,"uptimeSec":,"db":"ok","sessions":{}}`. If `db: "error"`, fix DB connectivity from the bot container (it uses the same `DATABASE_URL` as tools, so should work). -- [ ] **Step 7: Commit nothing (this task is verification only)** +- [ ] **Step 3: Verify graceful shutdown** + +```bash +scripts/dev.sh down +``` + +Expected: in `scripts/dev.sh logs bot` (run before down) you'll see "shutting down" log line just before container exits. `scripts/dev.sh status` exits non-zero with "tools container not running". + +- [ ] **Step 4: Bring it back up for the next tasks** + +```bash +scripts/dev.sh up +``` + +(No commit — verification only.) --- -## Task 10: Audit log writer +## Task 15: Audit log writer (TDD) **Files:** - Create: `apps/bot/src/audit.ts` @@ -1670,7 +1911,7 @@ Expected: same JSON. Then `scripts/dev.sh down`. Create `apps/bot/src/audit.test.ts`: ```typescript -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { DB } from "./db.js"; import { writeAuditLog } from "./audit.js"; @@ -1704,10 +1945,10 @@ describe("writeAuditLog", () => { }); ``` -- [ ] **Step 2: Run test (expect failure)** +- [ ] **Step 2: Run failing test** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 3: Implement `apps/bot/src/audit.ts`** @@ -1740,7 +1981,7 @@ export async function writeAuditLog(db: DB, input: AuditInput): Promise { - [ ] **Step 4: Run test (expect pass)** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 5: Commit** @@ -1752,16 +1993,16 @@ git -c commit.gpgsign=false commit -m "feat(bot): add audit log writer" --- -## Task 11: Telegram bot foundation (grammy, whitelist, /start, /help) +## Task 16: Telegram bot foundation (whitelist, /start, /help, audit middleware) **Files:** - Create: `apps/bot/src/telegram/bot.ts` - Create: `apps/bot/src/telegram/middleware/whitelist.ts` +- Create: `apps/bot/src/telegram/middleware/whitelist.test.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) +- Modify: `apps/bot/src/index.ts` (start the Telegram bot) - [ ] **Step 1: Write the failing test for whitelist middleware** @@ -1807,10 +2048,10 @@ describe("makeWhitelistMiddleware", () => { }); ``` -- [ ] **Step 2: Run test (expect failure)** +- [ ] **Step 2: Run failing test** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 3: Implement `apps/bot/src/telegram/middleware/whitelist.ts`** @@ -1822,7 +2063,7 @@ 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 (userId === undefined) return; if (!allowed.has(userId)) { await ctx.reply("Sorry, this bot is private."); return; @@ -1835,7 +2076,7 @@ export function makeWhitelistMiddleware(allowedUserIds: number[]): MiddlewareFn< - [ ] **Step 4: Run test (expect pass)** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 5: Implement `apps/bot/src/telegram/middleware/audit.ts`** @@ -1851,7 +2092,7 @@ export const auditMiddleware: MiddlewareFn = async (ctx, next) => { if (text?.startsWith("/")) { try { await writeAuditLog(db, { - operatorId: null, // resolved later when commands look up the operator row + operatorId: null, source: "telegram", action: `tg.command.${text.split(" ")[0]?.slice(1) ?? "unknown"}`, payload: { from: ctx.from?.id, text }, @@ -1925,7 +2166,7 @@ export function createTelegramBot(): Bot { - [ ] **Step 9: Wire bot into `apps/bot/src/index.ts`** -Replace the existing file: +Replace the file: ```typescript import { logger } from "./logger.js"; @@ -1963,26 +2204,33 @@ main().catch((err) => { }); ``` -- [ ] **Step 10: Run bot, send /start and /help via Telegram** +- [ ] **Step 10: Restart the bot service to pick up the new code** ```bash -scripts/dev.sh up -scripts/dev.sh logs +scripts/dev.sh restart-bot +scripts/dev.sh logs bot ``` -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." +Expected log line: `{"username":"","msg":"telegram polling started"}`. + +- [ ] **Step 11: Send `/start` and `/help` from your Telegram** to the dev bot + +Expected: welcome and help replies. As a non-whitelisted user (use a different account) sending `/start` should reply "Sorry, this bot is private." Verify in Postgres: ```bash -psql "$DATABASE_URL" -c "SELECT action, payload FROM audit_log ORDER BY created_at DESC LIMIT 5;" +scripts/dev.sh exec sh -c 'pnpm exec tsx -e " + import { Pool } from \"pg\"; + const p = new Pool({ connectionString: process.env.DATABASE_URL }); + const r = await p.query(\"SELECT action FROM audit_log ORDER BY created_at DESC LIMIT 5\"); + console.log(r.rows); + await p.end(); +"' ``` -Expected: rows for `tg.command.start` and `tg.command.help`. +Expected: includes `tg.command.start` and `tg.command.help`. -- [ ] **Step 11: Commit** +- [ ] **Step 12: Commit** ```bash git add apps/bot @@ -1991,7 +2239,7 @@ git -c commit.gpgsign=false commit -m "feat(bot): add telegram bot with whitelis --- -## Task 12: QR renderer (string → PNG buffer) +## Task 17: QR PNG renderer (TDD) **Files:** - Create: `apps/bot/src/whatsapp/qr-renderer.ts` @@ -2009,7 +2257,6 @@ 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"); }); @@ -2020,10 +2267,10 @@ describe("renderQrPng", () => { }); ``` -- [ ] **Step 2: Run test (expect failure)** +- [ ] **Step 2: Run failing test** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 3: Implement `apps/bot/src/whatsapp/qr-renderer.ts`** @@ -2040,7 +2287,7 @@ export async function renderQrPng(payload: string): Promise { - [ ] **Step 4: Run test (expect pass)** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 5: Commit** @@ -2052,12 +2299,12 @@ git -c commit.gpgsign=false commit -m "feat(bot): add QR PNG renderer" --- -## Task 13: Single-account Baileys session wrapper +## Task 18: 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. +This file isn't unit-tested directly — its value is the live WhatsApp connection, which the manual runbook covers in Task 22. - [ ] **Step 1: Implement `apps/bot/src/whatsapp/session.ts`** @@ -2079,8 +2326,7 @@ 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 }; + | { type: "close"; reason: number; loggedOut: boolean }; export type SessionEventHandler = (event: SessionEvent) => void | Promise; @@ -2143,11 +2389,9 @@ export async function startSession(params: { - [ ] **Step 2: Typecheck** ```bash -pnpm --filter @cmbot/bot typecheck +scripts/dev.sh pnpm --filter @cmbot/bot typecheck ``` -Expected: no errors. - - [ ] **Step 3: Commit** ```bash @@ -2157,11 +2401,12 @@ git -c commit.gpgsign=false commit -m "feat(bot): add Baileys session wrapper" --- -## Task 14: Session manager (lifecycle, reconnect, state machine) +## Task 19: Session manager (state machine + reconnect + DB sync) **Files:** - Create: `apps/bot/src/whatsapp/session-manager.ts` - Create: `apps/bot/src/whatsapp/session-manager.test.ts` +- Modify: `apps/bot/src/index.ts` (resume sessions on startup, expose counts to health) - [ ] **Step 1: Write the failing test for state transitions** @@ -2172,7 +2417,7 @@ import { describe, expect, it } from "vitest"; import { reduceState, type SessionState } from "./session-manager.js"; describe("reduceState", () => { - it("pending → connecting on start", () => { + it("pending → connecting on starting", () => { expect(reduceState("pending", { kind: "starting" })).toBe("connecting"); }); it("connecting → connected on open", () => { @@ -2191,16 +2436,16 @@ describe("reduceState", () => { "logged_out", ); }); - it("ignores stray events that don't match transitions (returns same state)", () => { + it("ignores stray events that don't match transitions", () => { expect(reduceState("connected", { kind: "starting" })).toBe("connected"); }); }); ``` -- [ ] **Step 2: Run test (expect failure)** +- [ ] **Step 2: Run failing test** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` - [ ] **Step 3: Implement `apps/bot/src/whatsapp/session-manager.ts`** @@ -2247,6 +2492,7 @@ class SessionManager { private sessions = new Map(); private states = new Map(); private listeners = new Set(); + private reconnectTimers = new Map(); on(listener: SessionListener): () => void { this.listeners.add(listener); @@ -2285,6 +2531,11 @@ class SessionManager { logger.debug({ accountId }, "session-manager: already running, ignoring start"); return; } + const existingTimer = this.reconnectTimers.get(accountId); + if (existingTimer) { + clearTimeout(existingTimer); + this.reconnectTimers.delete(accountId); + } this.transition(accountId, { kind: "starting" }); const session = await startSession({ @@ -2295,6 +2546,11 @@ class SessionManager { } async stop(accountId: string): Promise { + const timer = this.reconnectTimers.get(accountId); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(accountId); + } const session = this.sessions.get(accountId); if (!session) return; await session.close(); @@ -2305,7 +2561,6 @@ class SessionManager { 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 { const rows = await db .select({ id: whatsappAccounts.id, status: whatsappAccounts.status }) @@ -2340,10 +2595,11 @@ class SessionManager { .where(eq(whatsappAccounts.id, accountId)); if (!event.loggedOut) { - // Auto-reconnect after 5s - setTimeout(() => { + const timer = setTimeout(() => { + this.reconnectTimers.delete(accountId); void this.stop(accountId).then(() => this.start(accountId)); }, 5000); + this.reconnectTimers.set(accountId, timer); } else { await this.stop(accountId); } @@ -2354,7 +2610,6 @@ class SessionManager { .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); @@ -2380,12 +2635,12 @@ export const sessionManager = new SessionManager(); - [ ] **Step 4: Run test (expect pass)** ```bash -pnpm --filter @cmbot/bot test +scripts/dev.sh pnpm --filter @cmbot/bot test ``` -- [ ] **Step 5: Wire session counts into health and add session-manager startup to index.ts** +- [ ] **Step 5: Wire session-manager into `apps/bot/src/index.ts`** -Replace `apps/bot/src/index.ts`: +Replace the file: ```typescript import { logger } from "./logger.js"; @@ -2431,7 +2686,7 @@ main().catch((err) => { - [ ] **Step 6: Typecheck** ```bash -pnpm --filter @cmbot/bot typecheck +scripts/dev.sh pnpm --filter @cmbot/bot typecheck ``` - [ ] **Step 7: Commit** @@ -2443,7 +2698,7 @@ git -c commit.gpgsign=false commit -m "feat(bot): add session manager with state --- -## Task 15: Group sync (pull WA groups → upsert DB) +## Task 20: Group sync (pull WA groups → upsert DB) **Files:** - Create: `apps/bot/src/whatsapp/group-sync.ts` @@ -2498,7 +2753,7 @@ export async function syncGroupsForAccount( - [ ] **Step 2: Typecheck** ```bash -pnpm --filter @cmbot/bot typecheck +scripts/dev.sh pnpm --filter @cmbot/bot typecheck ``` - [ ] **Step 3: Commit** @@ -2510,7 +2765,7 @@ git -c commit.gpgsign=false commit -m "feat(bot): add group sync upsert" --- -## Task 16: /pair, /unpair, /accounts, /groups commands +## Task 21: /pair, /unpair, /accounts, /groups commands **Files:** - Create: `apps/bot/src/telegram/commands/pair.ts` @@ -2522,9 +2777,9 @@ git -c commit.gpgsign=false commit -m "feat(bot): add group sync upsert" - [ ] **Step 1: Implement `apps/bot/src/telegram/commands/pair.ts`** ```typescript -import type { Context, InputFile } from "grammy"; -import { InputFile as InputFileCtor } from "grammy"; -import { eq, and } from "drizzle-orm"; +import type { Context } from "grammy"; +import { InputFile } from "grammy"; +import { eq } from "drizzle-orm"; import { whatsappAccounts } from "@cmbot/db"; import { db } from "../../db.js"; import { logger } from "../../logger.js"; @@ -2546,12 +2801,11 @@ export async function handlePair(ctx: Context): Promise { 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`."); + await ctx.reply("Your Telegram ID is whitelisted but no operator row exists. Run scripts/db.sh seed."); return; } @@ -2574,13 +2828,12 @@ export async function handlePair(ctx: Context): Promise { 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 file = new InputFile(png, `pair-${id}.png`); const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`; const existingMsg = qrMessageIdByAccount.get(id); if (existingMsg) { @@ -2606,7 +2859,6 @@ export async function handlePair(ctx: Context): Promise { 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); @@ -2639,7 +2891,7 @@ export async function handlePair(ctx: Context): Promise { import type { Context } from "grammy"; import { rm } from "node:fs/promises"; import { join } from "node:path"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { whatsappAccounts } from "@cmbot/db"; import { db } from "../../db.js"; import { env } from "../../env.js"; @@ -2713,7 +2965,7 @@ export async function handleAccounts(ctx: Context): Promise { }); if (accounts.length === 0) { - await ctx.reply("No accounts paired yet. Use /pair \"Label\" to add one."); + await ctx.reply('No accounts paired yet. Use /pair "Label" to add one.'); return; } @@ -2810,10 +3062,11 @@ export function createTelegramBot(): Bot { } ``` -- [ ] **Step 6: Typecheck** +- [ ] **Step 6: Restart bot and typecheck** ```bash -pnpm --filter @cmbot/bot typecheck +scripts/dev.sh pnpm --filter @cmbot/bot typecheck +scripts/dev.sh restart-bot ``` - [ ] **Step 7: Commit** @@ -2825,7 +3078,7 @@ git -c commit.gpgsign=false commit -m "feat(bot): add /pair /unpair /accounts /g --- -## Task 17: Manual end-to-end pairing test +## Task 22: Manual end-to-end pairing test **Files:** - Create: `docs/superpowers/specs/manual-test-pairing.md` @@ -2839,9 +3092,9 @@ 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. +- `.env.development` filled in with real values. - `scripts/db.sh migrate && scripts/db.sh seed` ran clean. -- `scripts/dev.sh up` is running; `scripts/dev.sh logs` is tailing. +- `scripts/dev.sh up` is running; `scripts/dev.sh logs bot` is tailing. - Dev WhatsApp mock account installed on a test phone (NOT brother's prod accounts). - Dev Telegram bot opened in Telegram. @@ -2851,21 +3104,26 @@ automated — pairing requires a real phone scanning a QR. 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. + - Within ~5 seconds, a QR PNG arrives. +4. On the test phone: WhatsApp → Settings → Linked Devices → Link a Device → scan the QR. 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". + - "Synced N groups. Ready to send reminders." (N = number of WA groups). +6. Send `/accounts`. Expected: "• 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; + ```bash + scripts/dev.sh exec sh -c 'pnpm exec tsx -e " + import { Pool } from \"pg\"; + const p = new Pool({ connectionString: process.env.DATABASE_URL }); + console.log(\"accounts:\", (await p.query(\"SELECT label, status, phone_number FROM whatsapp_accounts\")).rows); + console.log(\"groups:\", (await p.query(\"SELECT count(*) FROM whatsapp_groups\")).rows); + console.log(\"audit:\", (await p.query(\"SELECT action FROM audit_log ORDER BY created_at DESC LIMIT 5\")).rows); + await p.end(); + "' ``` - 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. + Expected: account connected, groups present, audit shows `account.paired`. +9. Restart the bot: `scripts/dev.sh restart-bot`. 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`. @@ -2873,18 +3131,18 @@ automated — pairing requires a real phone scanning a QR. ## 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. +- **QR expiry:** ignore the QR for 30s. Bot edits 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." +- **Re-pair while connected:** send `/pair "Test Account 1"` again immediately after step 5. Expected: "already connected. Use /unpair first." ## Sign-off - [ ] All steps passed - [ ] Postgres rows match expectations -- [ ] No errors in `scripts/dev.sh logs` +- [ ] No errors in `scripts/dev.sh logs bot` - [ ] Tester: ____________ Date: ____________ ``` -- [ ] **Step 2: Run the manual test end-to-end against your dev mock account** +- [ ] **Step 2: Run the manual test 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. @@ -2897,7 +3155,7 @@ git -c commit.gpgsign=false commit -m "docs: add manual end-to-end pairing test --- -## Task 18: README and final commit +## Task 23: README and final push **Files:** - Create: `README.md` @@ -2913,27 +3171,29 @@ Self-hosted WhatsApp reminder bot. Pairs multiple WhatsApp accounts via Telegram **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/`). +## Host requirements + +Only Docker. No host Node, pnpm, or any other language toolchain — everything runs in containers via the long-lived `tools` service. + ## Quick start (dev) ```bash -# Prereqs: Node 22, pnpm 9, Docker, access to the home Postgres at 192.168.0.210 - -# 1. Configure env +# 1. Configure env (after creating dev DB on 192.168.0.210 and a dev Telegram bot) 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 +# 2. Bring up the tools container, install deps, run migrations +scripts/dev.sh up +scripts/dev.sh pnpm install scripts/db.sh migrate scripts/db.sh seed -# 3. Run the bot -scripts/dev.sh up -scripts/dev.sh logs +# 3. Watch the bot service +scripts/dev.sh logs bot ``` -Open Telegram, message your dev bot `/start`, then `/pair "Test"`. +In Telegram, message your dev bot `/start`, then `/pair "Test"`. ## Layout @@ -2946,9 +3206,11 @@ Open Telegram, message your dev bot `/start`, then `/pair "Test"`. ## Scripts -- `scripts/dev.sh up|down|logs|status|build` — local Docker stack lifecycle +All scripts run in the tools container (no host Node required). + +- `scripts/dev.sh up|down|logs|status|build|exec|pnpm|shell|restart-bot` — local stack lifecycle - `scripts/db.sh migrate|generate|studio|seed|reset` — Drizzle migration helper -- `scripts/gen_auth_secret.sh [--write]` — generate AUTH_SECRET +- `scripts/gen_auth_secret.sh [--write]` — generate AUTH_SECRET (host-only, no Node needed) - `scripts/publish.sh` — push to Gitea registry (plan 4) - `scripts/link-account.sh` — CLI pairing for dev (plan 2) @@ -2971,11 +3233,12 @@ Expected: push succeeds to `http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v ## Plan 1 done — what's working -After all 18 tasks: +After all 23 tasks: -- Repo skeleton on Gitea with all dev tooling. +- Repo skeleton on Gitea with Docker-only 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. +- `tools` container provides Node 22 + pnpm 9 for all install/test/typecheck operations — host needs only Docker. +- `bot` service runs in Docker with structured logging and a 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.