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