From 0fa4c724a5123d5a3db0fd6ee76846a5e310d1c6 Mon Sep 17 00:00:00 2001
From: Jazy1
Date: Fri, 7 Aug 2026 02:12:42 +0500
Subject: [PATCH] feat!: run Rumi without a Meta account, and set it up in two
commands
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rumi's messaging channel is now pluggable. The default links your own
WhatsApp by QR the way WhatsApp Web does, so a clone goes from `git clone`
to a working conversation in about fifteen minutes with no Business
account, no app review and no waiting. `rumi graduate` moves you to an
official number when you're ready, and every teacher, conversation and
past assessment carries over — Rumi identifies people by phone number,
not by channel.
The key insight for Meta-only features: a WhatsApp Flow is only a
*renderer*. The endpoint holds all the logic in a uniform shape, so the
sandbox needed a second renderer, not a second implementation. The new
text-flow engine drives those same endpoints over chat, which is why
/settings, /video, reading assessment and class setup work on a channel
that has no Flows.
Setup stopped being an eleven-step document and became `./install.sh`
then `rumi setup` — a five-step wizard that asks in plain language rather
than by variable name, checks every value against the real service as you
type it, writes each answer to .env immediately, and skips whatever
already works on a re-run. Plus `rumi start|status|doctor|pair|graduate`.
Most of the fixes here were pre-existing and affected Meta deployments
too; each failed inside a try/catch that made it look transient.
`redisService.setNX`/`setexWithCeiling` never existed, so no quiz could
ever be delivered and every image failed. `quiz_class_*` replies had no
handler. Five services bypassed llm-client. `quiz_sessions` was missing
six columns on any pre-existing database. `doctor` showed a green tick
for an OpenRouter key with no credit. Four paths resolved against the
working directory rather than the repo, which is how `cd bot && npm start`
loaded zero env vars and how a bot started from bot/ registered a second
WhatsApp device and re-synced until WhatsApp invalidated the first.
170 suites / 1997 tests, verified both locally and under CI's own
condition (root suite before `cd bot && npm ci`).
BREAKING CHANGE: `npm run setup` now launches the interactive wizard; use
`npm run doctor` for the preflight it used to run. `.env` is read from the
repo root rather than the process working directory — move a `bot/.env`
up one level (Railway is unaffected). `REQUIRED_VARS` is core-only, with
the channel's own variables in `CHANNEL_REQUIRED_VARS[CHANNEL_DRIVER]`;
existing Meta deployments need no change, since the driver is inferred as
`meta` when the four Meta variables are present.
Co-Authored-By: Claude Opus 5
---
.claude/skills/setup/SKILL.md | 244 ++-
.env.template | 57 +-
.gitignore | 5 +
CHANGELOG.md | 126 ++
CLAUDE.md | 16 +-
README.md | 336 ++-
SETUP.md | 77 +-
bin/rumi.js | 193 ++
bot/CLAUDE.md | 7 +-
bot/package-lock.json | 568 ++++-
bot/package.json | 4 +-
bot/scripts/setup/baileys-pair.js | 72 +
bot/scripts/setup/db-setup.js | 133 ++
bot/scripts/setup/doctor.js | 130 +-
bot/scripts/setup/env-file.js | 109 +
bot/scripts/setup/fields.js | 136 ++
bot/scripts/setup/graduate.js | 223 ++
bot/scripts/setup/interactive-setup.js | 755 +++++++
bot/scripts/setup/link-whatsapp.js | 137 ++
bot/scripts/setup/prompt.js | 299 +++
bot/scripts/setup/status.js | 147 ++
bot/scripts/setup/summary.js | 148 ++
bot/scripts/setup/ui.js | 377 ++++
bot/scripts/setup/validators.js | 223 ++
bot/shared/config/feature-availability.js | 65 +-
bot/shared/constants/feature-videos.js | 12 +-
bot/shared/handlers/image-message.handler.js | 26 +-
bot/shared/handlers/portal-command.handler.js | 19 +
bot/shared/handlers/text-message.handler.js | 153 +-
bot/shared/handlers/voice-message.handler.js | 120 +-
bot/shared/routes/student-videos-endpoint.js | 15 +-
.../services/attendance-detector.service.js | 10 +
.../services/cache/railway-redis.service.js | 90 +
.../coaching/coaching-helpers.service.js | 6 +-
.../coaching/transcript-enhancer.service.js | 11 +-
.../exam-checker/exam-session.service.js | 23 +-
bot/shared/services/feature-linker.service.js | 13 +-
.../messaging/baileys-channel.service.js | 801 +++++++
.../services/messaging/baileys-connection.js | 513 +++++
bot/shared/services/messaging/baileys-lib.js | 23 +
.../services/messaging/channel-registry.js | 35 +
.../services/messaging/endpoint-text-flow.js | 164 ++
.../inbound/baileys-socket.adapter.js | 535 +++++
bot/shared/services/messaging/index.js | 31 +
.../messaging/meta-channel.service.js | 1936 +++++++++++++++++
.../services/messaging/pending-options.js | 247 +++
.../messaging/text-flow-definitions.js | 339 +++
bot/shared/services/messaging/text-flow.js | 323 +++
.../services/pic-to-lp/classifier.service.js | 11 +-
.../pic-to-lp/metadata-extractor.service.js | 11 +-
.../services/quiz/quiz-delivery.service.js | 30 +-
.../services/quiz/quiz-generation.service.js | 25 +-
.../quiz/quiz-intent-router.service.js | 13 +-
.../services/quiz/quiz-report.service.js | 4 +-
.../services/quiz/quiz-session.service.js | 5 +-
.../quiz/video-quiz-report.service.js | 4 +-
.../services/reading/analysis.service.js | 104 +-
.../reading/passage-generation.service.js | 46 +-
.../services/reading/transcription.service.js | 18 +
bot/shared/services/student-list.service.js | 31 +-
bot/shared/services/whatsapp.service.js | 1926 +---------------
bot/shared/storage/r2.js | 14 +
bot/shared/utils/logger.js | 7 +
bot/shared/utils/structured-logger.js | 67 +-
bot/whatsapp-bot.js | 129 +-
docs/onboarding/sandbox-production-design.md | 839 +++++++
infrastructure/CLAUDE.md | 2 +-
.../supabase/00_complete-schema.sql | 44 +
install.sh | 111 +
package-lock.json | 213 +-
package.json | 13 +-
tests/__mocks__/ffmpeg-installer.js | 8 +
tests/__mocks__/fluent-ffmpeg.js | 55 +
tests/cache/redis-missing-methods.test.js | 114 +
tests/jest.config.js | 6 +
.../messaging/baileys-channel-service.test.js | 386 ++++
tests/messaging/baileys-connection.test.js | 727 +++++++
.../messaging/baileys-socket-adapter.test.js | 710 ++++++
tests/messaging/channel-driver-index.test.js | 78 +
tests/messaging/channel-driver-parity.test.js | 128 ++
tests/messaging/channel-lifecycle.test.js | 63 +
tests/messaging/channel-registry.test.js | 29 +
tests/messaging/endpoint-text-flow.test.js | 292 +++
tests/messaging/pending-options.test.js | 295 +++
tests/messaging/text-flow-definitions.test.js | 218 ++
tests/messaging/text-flow.test.js | 219 ++
.../audio-without-object-storage.test.js | 137 ++
tests/setup/_audit-helpers/require-graph.js | 6 +-
tests/setup/bin-rumi.test.js | 198 ++
tests/setup/cli-console.test.js | 205 ++
tests/setup/db-setup.test.js | 103 +
tests/setup/doctor.test.js | 169 +-
tests/setup/env-file.test.js | 101 +
tests/setup/env-template-completeness.test.js | 12 +
tests/setup/fake-io.js | 77 +
tests/setup/fields.test.js | 91 +
tests/setup/graduate.test.js | 184 ++
tests/setup/interactive-setup.test.js | 508 +++++
tests/setup/llm-single-entry-point.test.js | 124 ++
.../setup/no-undefined-redis-methods.test.js | 128 ++
.../no-undefined-whatsapp-methods.test.js | 9 +-
tests/setup/prompt.test.js | 139 ++
tests/setup/status.test.js | 198 ++
tests/setup/ui.test.js | 176 ++
tests/setup/validators.test.js | 177 ++
tests/unit/no-undeliverable-promises.test.js | 76 +
tests/unit/sprint-0/security-scan.test.js | 5 +
.../sprint-1/feature-availability.test.js | 66 +-
.../sprint-3/setup-infrastructure.test.js | 24 +-
tests/unit/sprint-4/docs-completeness.test.js | 4 +-
tests/unit/student-list-parsing.test.js | 128 ++
tests/whatsapp/send-image-from-url.test.js | 7 +
tests/whatsapp/send-template.test.js | 8 +
113 files changed, 17495 insertions(+), 2592 deletions(-)
create mode 100755 bin/rumi.js
create mode 100755 bot/scripts/setup/baileys-pair.js
create mode 100644 bot/scripts/setup/db-setup.js
create mode 100644 bot/scripts/setup/env-file.js
create mode 100644 bot/scripts/setup/fields.js
create mode 100755 bot/scripts/setup/graduate.js
create mode 100755 bot/scripts/setup/interactive-setup.js
create mode 100644 bot/scripts/setup/link-whatsapp.js
create mode 100644 bot/scripts/setup/prompt.js
create mode 100644 bot/scripts/setup/status.js
create mode 100644 bot/scripts/setup/summary.js
create mode 100644 bot/scripts/setup/ui.js
create mode 100644 bot/scripts/setup/validators.js
create mode 100644 bot/shared/services/messaging/baileys-channel.service.js
create mode 100644 bot/shared/services/messaging/baileys-connection.js
create mode 100644 bot/shared/services/messaging/baileys-lib.js
create mode 100644 bot/shared/services/messaging/channel-registry.js
create mode 100644 bot/shared/services/messaging/endpoint-text-flow.js
create mode 100644 bot/shared/services/messaging/inbound/baileys-socket.adapter.js
create mode 100644 bot/shared/services/messaging/index.js
create mode 100644 bot/shared/services/messaging/meta-channel.service.js
create mode 100644 bot/shared/services/messaging/pending-options.js
create mode 100644 bot/shared/services/messaging/text-flow-definitions.js
create mode 100644 bot/shared/services/messaging/text-flow.js
create mode 100644 docs/onboarding/sandbox-production-design.md
create mode 100755 install.sh
create mode 100644 tests/__mocks__/ffmpeg-installer.js
create mode 100644 tests/__mocks__/fluent-ffmpeg.js
create mode 100644 tests/cache/redis-missing-methods.test.js
create mode 100644 tests/messaging/baileys-channel-service.test.js
create mode 100644 tests/messaging/baileys-connection.test.js
create mode 100644 tests/messaging/baileys-socket-adapter.test.js
create mode 100644 tests/messaging/channel-driver-index.test.js
create mode 100644 tests/messaging/channel-driver-parity.test.js
create mode 100644 tests/messaging/channel-lifecycle.test.js
create mode 100644 tests/messaging/channel-registry.test.js
create mode 100644 tests/messaging/endpoint-text-flow.test.js
create mode 100644 tests/messaging/pending-options.test.js
create mode 100644 tests/messaging/text-flow-definitions.test.js
create mode 100644 tests/messaging/text-flow.test.js
create mode 100644 tests/reading/audio-without-object-storage.test.js
create mode 100644 tests/setup/bin-rumi.test.js
create mode 100644 tests/setup/cli-console.test.js
create mode 100644 tests/setup/db-setup.test.js
create mode 100644 tests/setup/env-file.test.js
create mode 100644 tests/setup/fake-io.js
create mode 100644 tests/setup/fields.test.js
create mode 100644 tests/setup/graduate.test.js
create mode 100644 tests/setup/interactive-setup.test.js
create mode 100644 tests/setup/llm-single-entry-point.test.js
create mode 100644 tests/setup/no-undefined-redis-methods.test.js
create mode 100644 tests/setup/prompt.test.js
create mode 100644 tests/setup/status.test.js
create mode 100644 tests/setup/ui.test.js
create mode 100644 tests/setup/validators.test.js
create mode 100644 tests/unit/no-undeliverable-promises.test.js
create mode 100644 tests/unit/student-list-parsing.test.js
diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md
index 850c629..713b50d 100644
--- a/.claude/skills/setup/SKILL.md
+++ b/.claude/skills/setup/SKILL.md
@@ -2,101 +2,140 @@
> **Up:** [.claude/CLAUDE.md](../../CLAUDE.md) (config & skills router) · **Architecture overview:** [digital-coach](../digital-coach/SKILL.md)
-Automated setup guide for deploying the AI teaching-assistant bot. This skill walks through the complete
-setup interactively. Once the bot is running, the [digital-coach](../digital-coach/SKILL.md) skill is the
-map to everything else.
+Setting up a Rumi deployment. Once the bot is running, the [digital-coach](../digital-coach/SKILL.md) skill
+is the map to everything else.
-## What This Does
+## Two ways in — pick the right one before you touch anything
-1. **Pre-flight checks**: Verifies Node.js 18+, git, npm (`npm run doctor`)
-2. **Feature selection (presence-based)**: there are **no tiers** — a feature turns on when the env vars it needs are present. Set the keys for the features you want; leave the rest unset and the bot degrades gracefully.
-3. **Infrastructure setup**: Creates Supabase project + Railway project + Redis manually
-4. **WhatsApp config**: Set webhook URL, verify handshake
-5. **Register flows**: WhatsApp Flows for interactive forms
-6. **E2E test**: Send test message, verify response
+Setting Rumi up is one sequence with two front doors. Both are first-class; they differ only in who types.
-## Usage
+| | **`rumi setup`** | **"Set me up"** (this skill) |
+|---|---|---|
+| Who answers the questions | the user, in their own terminal | the user, in conversation with you |
+| Needs a TTY | yes | no |
+| Use when | they want to drive it themselves | they asked *you* to do it |
-```
-/setup
-```
+### Never run the interactive commands yourself
-The agent will guide you through each step interactively.
+**`rumi setup`, `rumi pair` and `rumi graduate` are interactive TTY programs** — arrow-key menus, masked
+input, a QR code to scan with a phone. Launched from a tool call they stop at the first prompt and wait
+forever, because there is no keyboard attached. If the user should run one, *tell them to* and stop.
-## Prerequisites
+| Agent-safe (non-interactive) | Human-only (needs a keyboard) |
+|---|---|
+| `./install.sh` — skips its one prompt when stdin isn't a terminal | `rumi setup` |
+| `rumi doctor` (same as `npm run doctor`), `rumi status` | `rumi pair` — also needs a phone to scan |
+| `npm run validate:env`, `npm run bootstrap:db`, `npm test` | `rumi graduate` |
-- Node.js 18+ installed
-- WhatsApp Business credentials (from Meta Business Manager)
-- Supabase account (free tier works)
-- Railway account (free tier works)
+If `rumi` isn't on the PATH, `node bin/rumi.js ` is identical — and each has an `npm run` equivalent
+in `package.json`, which is the safer form to reach for inside a tool call.
-## Feature selection (presence-based, no tiers)
+## Doing it yourself: the "set me up" flow
-Gating is by **presence of keys**, not a tier flag. Start with the required core; add each feature's keys
-when you want it on. `.env.template` documents every feature's keys under an `ENABLES:` heading, and
-`npm run validate:env` reports which features are currently switched on.
+Use the wizard's **own modules** rather than a prose re-implementation. They are the same code `rumi setup`
+runs, so the two paths cannot drift, and you inherit every shape check and live probe for free.
-| To run… | Set these (on top of the core) |
-|---------|-------------------------------|
-| **Core** (AI chat + registration) | `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `REDIS_URL`, `OPENROUTER_API_KEY`, WhatsApp creds |
-| Voice transcription | `SONIOX_API_KEY` |
-| Spoken replies (TTS) | `ELEVENLABS_API_KEY` (+ `UPLIFT_API_KEY` for Urdu/regional) |
-| Reading pronunciation scoring | `AZURE_SPEECH_KEY` |
-| Lesson-plan generation | `GAMMA_API_KEY` |
-| Educational video | `VIDEO_GENERATION_ENABLED=true` + `KIE_API_KEY` |
-
-The single source of truth for what each key enables is
-[bot/shared/config/feature-availability.js](../../../bot/shared/config/feature-availability.js).
+### A. Collect the values, in plain language
-## Setup Steps (Detailed)
+Ask for one service at a time, in the user's terms — "the project URL from Supabase", never `SUPABASE_URL`.
+The order that works: database → AI → Redis → optional extras → WhatsApp.
-### Step 1: Pre-flight Checks
+**Check each answer with the real validator before you accept it.** These exist because the expensive
+mistakes are *well-formed values for the wrong thing*, and they name the specific fix:
-```bash
-node --version # Must be 18+
-npm --version
-git --version
+```js
+const v = require('./bot/scripts/setup/validators');
+v.supabaseServiceKey(pasted); // → { ok:false, reason:'That is the anon (public) key…' }
+v.phoneNumberId(pasted); // → catches a phone number in Meta's id field
+v.openrouterKey(pasted); // → names which vendor's key was pasted by mistake
+v.validatorFor('ANY_ENV_VAR'); // → the right one, or a presence check
```
-### Step 2: Create Infrastructure
+A validator may return a cleaned `value` (trimmed URL, wrapped `host:port`) — store that, not the raw paste.
+
+### B. Write them to `.env`
-Follow [SETUP.md](../../../SETUP.md) to manually create:
-- Supabase project (copy URL + service role key)
-- Railway project + Redis plugin
-- OpenRouter API key
+```js
+const { readEnvFile, writeEnvVars } = require('./bot/scripts/setup/env-file');
+writeEnvVars('.env', { SUPABASE_URL: url, SUPABASE_SERVICE_ROLE_KEY: key },
+ { fromTemplatePath: '.env.template' });
+```
-Copy the credentials into `.env` based on `.env.template`.
+Patches in place — every comment, every unrelated var untouched. **Write after each service, not at the end**,
+so an interrupted conversation costs nothing. Never regenerate `.env` from the template.
-### Step 3: Bootstrap the Database
+### C. Verify against the live service
-One command applies the schema, RLS policies, and seed data in order:
+"Set" is not "working". Use the same probes the doctor uses:
-```bash
-npm run bootstrap:db
+```js
+const { defaultProbes } = require('./bot/scripts/setup/doctor');
+await defaultProbes.supabase(env); // { ok, detail }
+await defaultProbes.openrouter(env); // also reports the credit balance
+await defaultProbes.redis(env);
```
-(Equivalent manual apply: `psql $DATABASE_URL -f infrastructure/supabase/00_complete-schema.sql`, then
-`01_rls-policies.sql`, then `02_seed-data.sql`.)
+A valid OpenRouter key with **no credit** is a distinct case: it answers greetings and then fails on anything
+substantial. Tell the user, and let them decide whether to add credit now.
-Then confirm your environment is wired correctly before deploying:
+### D. Create the tables
-```bash
-npm run validate:env # which features are switched on (by key presence)
-npm run doctor # connection + config preflight
+```js
+const db = require('./bot/scripts/setup/db-setup');
+await db.inspectDatabase(env); // 'ready' | 'needs-schema' | 'needs-helper' | 'unreachable'
```
-### Step 4: Add WhatsApp Credentials
+- **`ready`** — nothing to do.
+- **`needs-schema`** — run `db.applySchema(env)` (or `npm run bootstrap:db`).
+- **`needs-helper`** — **this step needs the user's hands.** Supabase exposes no API for arbitrary SQL, so the
+ `exec_sql` function the schema is applied through has to be pasted in once. Give them
+ `db.EXEC_SQL_DEFINITION` (two lines) and `db.sqlEditorUrl(env.SUPABASE_URL)` — a link straight to *their*
+ project's SQL editor — then wait, re-check with `db.hasExecSql(env)`, and apply.
+- **`unreachable`** — the key was rejected or the host didn't answer; don't proceed as if there were no tables.
-Edit `.env` and add your WhatsApp credentials (from Meta Business Manager):
+### E. Connect WhatsApp
-```env
-WHATSAPP_TOKEN=EAA...
-PHONE_NUMBER_ID=123456789
-WABA_ID=987654321
-WEBHOOK_VERIFY_TOKEN=your-random-string
-```
+Ask the plain-language question, not "which channel driver":
+
+> **Just trying it out** — links their own WhatsApp like WhatsApp Web. Nothing to register.
+> **Real deployment** — an official WhatsApp Business number through Meta.
+
+**Trying it out:** write `CHANNEL_DRIVER=baileys`, `QUEUE_DRIVER=bullmq` (the template default is `sqs`, which
+needs an AWS account they do not have) and `CHANNEL_STATE_DIR=.channel-state`. Then **hand pairing to the
+user** — it needs a phone camera:
+
+> Run `rumi pair` in your terminal and scan the code with WhatsApp → Settings → Linked devices.
+
+Say the caveats first: it becomes a linked device on their personal account and can see their chats; the
+Meta-only surfaces (tap-through forms, approved templates, picture carousels) render as an ordinary chat
+instead; and **they need a second phone number to message it from**, because Rumi *is* their number.
+
+**Real deployment:** the four Meta values, with the on-page names and guidance already written in
+[bot/scripts/setup/fields.js](../../../bot/scripts/setup/fields.js) (`META_FIELDS` — use its `label`, `hint`
+and `validate`; `WEBHOOK_VERIFY_TOKEN` has a `generate()`). Then `defaultProbes.whatsapp(env)`, then
+`META_REMAINING_STEPS` for what only they can do in Meta's console.
-### Step 5: Install & Deploy
+### F. Finish
+
+Run `rumi doctor` and read it back in plain language. Then tell them: `rumi start`, message the number from
+their **second** number, and try `Hi`, `/menu`, `/reading test`.
+
+### What you add that the wizard cannot
+
+1. **Deciding which path they're on** — trying it out vs a real deployment changes everything downstream. If
+ they haven't said, ask. See
+ [docs/onboarding/sandbox-production-design.md](../../../docs/onboarding/sandbox-production-design.md).
+2. **Explaining *why* a step exists** when someone stalls, and interpreting a failure in their words.
+3. **The production steps below** — hosting, the Meta webhook, Flow registration, the background worker.
+4. **Customization afterwards** — the table at the end.
+
+## Production steps the wizard does not do
+
+
+### Deploy
+
+Rumi runs on any Node host. **Railway** is the documented default, and what
+`infrastructure/railway/` is configured for:
```bash
cd bot && npm install && cd ..
@@ -104,16 +143,24 @@ railway login
railway up
```
-### Step 6: Configure WhatsApp Webhook
+For Railway specifics — scaling, logs, the worker process — see
+[docs/railway-operations.md](../../../docs/railway-operations.md).
-1. Go to Meta Business Manager > WhatsApp > Configuration > Webhook
-2. Set URL: `https://your-app.up.railway.app/webhook`
-3. Set verify token: same as `WEBHOOK_VERIFY_TOKEN`
-4. Subscribe to: `messages`
+### Configure the WhatsApp webhook (Meta only)
-### Step 7: Register WhatsApp Flows & Templates
+1. Meta Business Manager → WhatsApp → Configuration → Webhook
+2. Callback URL: `https://your-app.up.railway.app/webhook`
+3. Verify token: the same value as `WEBHOOK_VERIFY_TOKEN` in `.env` (the wizard calls this the "webhook
+ password" and can generate one)
+4. **Subscribe to the `messages` field** — without it Meta accepts the URL and then never sends anything,
+ which looks exactly like a broken bot
-After deploying, register the WhatsApp Flows (interactive forms) and Message Templates with Meta:
+### Register WhatsApp Flows & templates (Meta only)
+
+Flows are Meta-only interactive forms. On the sandbox channel they are not used at all — the same endpoint
+logic is rendered as a text conversation instead (see
+[bot/shared/services/messaging/text-flow-definitions.js](../../../bot/shared/services/messaging/text-flow-definitions.js)),
+so there is nothing to register.
```bash
node bot/scripts/setup/run-full-setup.js \
@@ -123,33 +170,46 @@ node bot/scripts/setup/run-full-setup.js \
--endpoint-base=https://your-app.up.railway.app
```
-The script will:
-1. Generate RSA-2048 encryption keys
-2. Register flows: Reading Assessment, Attendance Setup, Attendance Marking
-3. Submit message templates
+It generates the RSA-2048 keypair, registers the Flows, and submits the message templates. Set the values it
+prints as env vars on the host: `READING_ASSESSMENT_FLOW_ID`, `ATTENDANCE_SETUP_FLOW_ID`,
+`ATTENDANCE_MARKING_FLOW_ID`, `REGISTRATION_FLOW_ID`, `FLOW_PRIVATE_KEY` (base64).
-**Output**: Flow IDs and env var values to set in Railway:
-- `READING_ASSESSMENT_FLOW_ID`
-- `ATTENDANCE_SETUP_FLOW_ID`
-- `ATTENDANCE_MARKING_FLOW_ID`
-- `FLOW_PRIVATE_KEY` (base64-encoded)
+### Background worker
-Set the output values as Railway env vars:
+The coaching pipeline needs the stale-session worker on a schedule (every 15 minutes):
+`node bot/workers/stale-session.worker.js`. See SETUP.md Step 11.
-```bash
-railway variables set READING_ASSESSMENT_FLOW_ID=
-railway variables set ATTENDANCE_SETUP_FLOW_ID=
-railway variables set ATTENDANCE_MARKING_FLOW_ID=
-railway variables set FLOW_PRIVATE_KEY=
-```
+### Test
+
+Send "Hi" to the number. Expected: a welcome message and the registration prompt. If nothing arrives, check
+the webhook is subscribed to `messages`, then the host's logs.
+
+## Feature gating (presence-based, no tiers)
+
+A feature is on iff the env vars it needs are present — there is no tier flag and no master switch. The
+wizard's step 4 offers the common ones; anything can be added later by setting its key and restarting.
+`rumi status` and `rumi doctor` both list what is currently on and which key would switch each remaining one
+on.
-### Step 8: Test
+| To run… | Set these (on top of the core) |
+|---------|-------------------------------|
+| **Core** (AI chat + registration) | `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `REDIS_URL`, `OPENROUTER_API_KEY`, + the channel's own vars |
+| Voice notes / reading assessment | `SONIOX_API_KEY` |
+| Spoken replies | `ELEVENLABS_API_KEY` (+ `UPLIFT_API_KEY` for Urdu/regional) |
+| Reading pronunciation scoring | `AZURE_SPEECH_KEY`, `AZURE_SPEECH_REGION` |
+| Lesson-plan generation | `GAMMA_API_KEY` |
+| Educational video | `VIDEO_GENERATION_ENABLED=true` + `KIE_API_KEY` |
+| Exam-checker OCR | `MISTRAL_API_KEY` or `CHANDRA_API_KEY` |
-Send "Hi" to your WhatsApp number. Expected: welcome message + registration flow.
+The single source of truth is
+[bot/shared/config/feature-availability.js](../../../bot/shared/config/feature-availability.js) — read it
+rather than trusting this table if they disagree.
## Resuming
-If setup fails partway through, run `/setup` again. It reads `.setup-state.json` and resumes from the last completed step.
+Both halves resume: `rumi setup` saves each answer to `.env` as it is given and skips whatever already works,
+so re-running after an interruption costs a few seconds. Flow registration keeps its own progress in
+`.setup-state.json`, which `rumi doctor` reads to report which Flows are registered.
## After Setup: Customization
diff --git a/.env.template b/.env.template
index 7404deb..868bcec 100644
--- a/.env.template
+++ b/.env.template
@@ -2,25 +2,39 @@
# Rumi Platform — Environment Configuration
# ============================================================================
#
+# YOU PROBABLY DO NOT NEED TO EDIT THIS BY HAND.
+#
+# ./install.sh && rumi setup
+#
+# fills all of it in for you, asking in plain language, checking each value
+# against the real service as you enter it, and creating the database tables.
+# Read on only if you would rather do it yourself, or you are on a machine with
+# no interactive terminal.
+#
+# ----------------------------------------------------------------------------
# HOW THIS WORKS (read this first — also true for your AI setup agent):
#
# 1. Copy this file: cp .env.template .env
-# 2. Fill in the REQUIRED block below (8 values). That alone boots the bot
-# with AI chat + registration.
+# 2. Fill in the REQUIRED block below (4 core values), plus whichever
+# CHANNEL_DRIVER you pick (baileys needs none; meta needs 4 more). That
+# alone boots the bot with AI chat + registration.
# 3. Features turn on by PRESENCE. Set a feature's keys and that feature
# switches on automatically — there is no master "enable" flag to flip.
# Leave a feature's keys blank and that feature stays off cleanly (the
# bot will never crash because a key is missing; it just won't offer
# that feature). Each optional block below says exactly which keys
-# switch it on.
-# 4. Verify what you configured: npm run doctor
+# switch it on. The messaging channel works the same way — see
+# CHANNEL_DRIVER below.
+# 4. Verify what you configured: rumi doctor (or: npm run doctor)
# (prints a green/red matrix of every service and which features are live)
+# rumi status also shows whether the bot is running and which WhatsApp
+# number it answers as.
#
# Any value shown as CHANGEME, your-..., or a placeholder URL MUST be replaced.
# Never commit your real .env — only this template is tracked (.env is gitignored).
#
# ============================================================================
-# ███ REQUIRED — the bot will not start without these 8 ███████████████████
+# ███ REQUIRED — the bot will not start without these 4 core values ███████
# ============================================================================
# Node environment: production | development | test
@@ -34,6 +48,26 @@ PORT=3000
# Leave blank in production (Railway gives you a public URL). NEVER commit a real token.
NGROK_AUTHTOKEN=
+# --- Messaging channel — how the bot talks to WhatsApp -----------------------
+# baileys (default): sandbox mode — pairs with your own WhatsApp via a QR code,
+# no Meta account needed. Good for trying Rumi out or local development.
+# Pair with: npm run pair:baileys (or: node bot/scripts/setup/baileys-pair.js)
+# Sends/receives text, image, audio, and document messages once paired.
+# WhatsApp Flows, approved templates, and carousels are Meta-only — there is
+# no Baileys equivalent yet, so those log clearly instead of sending.
+# `npm run doctor` reports config readiness, not "messaging works" — pair
+# and send yourself a test message to confirm that end to end.
+# meta: WhatsApp Cloud API, for a real deployment — requires the WhatsApp
+# block further down. See docs/onboarding/sandbox-production-design.md.
+CHANNEL_DRIVER=baileys
+
+# Where the Baileys sandbox driver stores its local WhatsApp Web session
+# (auth keys) — one var, regardless of how many drivers exist. Pair with:
+# node bot/scripts/setup/baileys-pair.js
+# Treat this directory like a credential (it grants live account access) —
+# it's gitignored (.channel-state/) and must never be committed.
+CHANNEL_STATE_DIR=.channel-state
+
# --- Database (Supabase) — create a free project at https://supabase.com ----
# Your Supabase project URL
@@ -53,9 +87,10 @@ OPENROUTER_API_KEY=CHANGEME-sk-or-v1-your-openrouter-key
REDIS_URL=redis://localhost:6379
# --- WhatsApp — from Meta Business Manager (see docs/whatsapp-setup) ---------
-# Fill these manually OR via `npm run setup:flows` (which registers Flow assets
-# and prints PHONE_NUMBER_ID / WABA_ID for you on first run). Full step-by-step:
-# docs/onboarding/whatsapp.md.
+# Required only if CHANNEL_DRIVER=meta above — leave blank for the default
+# baileys (sandbox) driver. Fill these manually OR via `npm run setup:flows`
+# (which registers Flow assets and prints PHONE_NUMBER_ID / WABA_ID for you on
+# first run). Full step-by-step: docs/onboarding/whatsapp.md.
# WhatsApp Cloud API access token (a permanent system-user token)
WHATSAPP_TOKEN=CHANGEME-whatsapp-token
@@ -213,6 +248,12 @@ DEFAULT_PHONE_COUNTRY_CODE=
# bullmq → BullMQ on Redis. Needs ONLY REDIS_URL (no AWS account).
# bullmq is the zero-AWS path: if you already run Redis for caching, set this to
# bullmq and you can skip the entire AWS SQS block.
+#
+# SANDBOX: use bullmq. `rumi setup` sets it for you when you pick "just testing
+# things out", because a sandbox has REDIS_URL but no AWS account — leaving this
+# at `sqs` means every queued job dies with "SQS Queue not configured", and some
+# of those jobs are load-bearing (a quiz that was generated AND delivered
+# reported itself to the teacher as failed, because scheduling its report threw).
QUEUE_DRIVER=sqs
# Optional BullMQ queue-name overrides (defaults shown):
# BULLMQ_MAIN_QUEUE=rumi-main
diff --git a/.gitignore b/.gitignore
index 1436a77..f5ac110 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,11 @@ keys/
*.key
*.cert
*.p12
+
+# Messaging channel local state (e.g. .channel-state/baileys/ session auth) —
+# grants live account access, treat like a credential. See CHANNEL_STATE_DIR
+# in .env.template.
+.channel-state/
credentials*
service-account*.json
.railway-token
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae34818..8f73503 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,132 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.0.0] - 2026-08-07
+
+**Rumi no longer requires a Meta WhatsApp Business account to run.** The messaging
+channel is now pluggable: the default links your own WhatsApp by QR the way
+WhatsApp Web does, so a clone goes from `git clone` to a working conversation in
+about fifteen minutes with no Business account, no app review and no waiting.
+When you're ready for a real deployment, `rumi graduate` moves you to an official
+number and every teacher, conversation and past assessment carries over.
+
+Alongside it, setup stopped being an eleven-step document and became two
+commands.
+
+### BREAKING (vs v1.2.0)
+
+- **`npm run setup` now launches the interactive setup wizard.** It previously
+ ran the preflight (`doctor.js`). If you had it in a script or a deploy step,
+ switch to **`npm run doctor`** (or `rumi doctor`) — same output, unchanged.
+- **`.env` is read from the repo root, not the process working directory.**
+ `bot/whatsapp-bot.js`, `bin/rumi.js` and `bot/scripts/setup/doctor.js` now
+ resolve it relative to the repository. If you kept a `bot/.env`, move it to the
+ repo root. Railway is unaffected — its Procfile already runs from the root.
+ This fixed a real failure: `cd bot && npm start` loaded **zero** variables and
+ aborted with "Missing REQUIRED env var(s)" on a fully configured deployment.
+- **`REQUIRED_VARS` is now core-only** (`SUPABASE_URL`,
+ `SUPABASE_SERVICE_ROLE_KEY`, `OPENROUTER_API_KEY`, `REDIS_URL`); the channel's
+ own variables come from `CHANNEL_REQUIRED_VARS[CHANNEL_DRIVER]`. **Existing Meta
+ deployments need no change** — with `CHANNEL_DRIVER` unset and the four Meta
+ variables present, the driver is inferred as `meta`.
+- **`CHANNEL_STATE_DIR` (default `.channel-state`) resolves against the repo**,
+ not the working directory. Only affects the new sandbox driver, but it is the
+ reason a bot started from `bot/` registered a *second* WhatsApp device and
+ re-synced endlessly until WhatsApp invalidated the first.
+
+### Added
+
+- **A two-layer CLI.** `./install.sh` does the mechanical bootstrap (tool check,
+ dependencies, `.env`, puts `rumi` on your PATH) and offers to run the wizard;
+ `rumi` does everything else: `setup`, `start`, `status`, `doctor`, `pair`,
+ `graduate`.
+- **`rumi setup` — a five-step guided wizard.** Asks in plain language rather
+ than by variable name ("where should Rumi keep its memory", not
+ `SUPABASE_URL`), checks every value against the real service as you type it
+ using the same probes `rumi doctor` runs, writes each answer to `.env`
+ immediately (so Ctrl+C costs nothing), and skips anything already working on a
+ re-run. Creates the full database — 76 tables, RLS policies and seed data —
+ inline.
+- **Pluggable messaging channels** via `CHANNEL_DRIVER`. A registry
+ (`bot/shared/services/messaging/channel-registry.js`) with an explicit
+ production-tier allowlist; `whatsapp.service.js` is now a one-line facade over
+ it, so all ~40 existing call sites are untouched. Adding a channel later is a
+ new registry key plus a service file.
+- **The Baileys sandbox driver** — QR pairing, text, reactions, typing
+ indicators, images, audio, documents, video and stickers, plus an inbound
+ adapter that normalizes a socket event into the same shape Meta's webhook
+ produces, so the existing dispatch runs unchanged.
+- **WhatsApp Flows, rendered as a conversation.** A Flow is only a renderer; the
+ endpoint holds the logic. The new text-flow engine drives those *same*
+ endpoints over chat, so `/settings`, `/video`, reading assessment and class
+ setup work on a channel that has no Flows — with the field names pinned by
+ tests against their real consumers.
+- **`rumi graduate`** — collects the target channel's credentials, validates them
+ against the live service *before* touching `.env`, retires (never deletes) the
+ outgoing session, and prints the checklist for what only you can do in Meta's
+ console.
+- **`rumi status`** — is Rumi running, which WhatsApp number it answers as, and
+ what's switched on. Reads the connection module's own lock rather than
+ inventing a second source of truth.
+- **Field-shape validation with specific corrections.** Catches Supabase's
+ **anon** key pasted instead of `service_role` (both are `eyJ…` JWTs on the same
+ page — the anon key cannot see past RLS, so the bot runs and finds no data), a
+ phone *number* in Meta's `PHONE_NUMBER_ID`, another vendor's `sk-…` in
+ `OPENROUTER_API_KEY`, the Supabase dashboard URL instead of the API URL, and
+ Upstash's `https://` endpoint as `REDIS_URL`.
+- **An optional-abilities step** that describes each extra by what a teacher
+ would notice, defaults to skipping, and only stores a multi-key feature when
+ every key is given.
+
+### Fixed
+
+Most of these were pre-existing and affected Meta deployments too. Each failed
+inside a `try/catch` that made it look transient.
+
+- **`redisService.setNX` and `setexWithCeiling` never existed.** No quiz could
+ ever be delivered and every image message failed. Added, with a conformance
+ guard.
+- **`quiz_class_*` replies had no handler**, despite a comment claiming one.
+- **Five services bypassed `llm-client.js`** and called `OPENAI_API_KEY`
+ directly.
+- **`quiz_sessions` was missing six columns** on any database created before
+ them — `CREATE TABLE IF NOT EXISTS` is a no-op on an existing table, so they
+ only ever reached fresh installs. Added to the `ALTER … ADD COLUMN IF NOT
+ EXISTS` reconcile block.
+- **`rumi doctor` reported a green tick for an OpenRouter key with no credit** —
+ the worst kind of preflight, since it sends you hunting for a bug in the bot.
+ It now reports the remaining balance.
+- **Feature-intro videos and reading-passage backgrounds produced relative URLs**
+ when no public asset host was configured, so the bot offered "want to see how?
+ 🎥", the teacher accepted, and nothing arrived. Both are presence-gated now,
+ and the offer is only made when there is something to send.
+- **Reading assessments leaked artifacts** — every run left an `.ogg` of a
+ child's voice and a report PDF on disk forever.
+- **A failure message claimed "our team has been notified"** when nobody had
+ been. Replaced with an honest one.
+- **A failed voice note apologised three times.**
+- Baileys sessions are protected by a single-instance lock, and a QR shown when
+ credentials already exist is treated as terminal rather than looping forever
+ (which is how this project kept tripping WhatsApp's device-linking rate limit).
+- Two tests read the repo's real channel state; one renamed a live WhatsApp
+ session. Both now use throwaway directories.
+
+### Changed
+
+- **README and SETUP.md** lead with the two-command path; the manual walkthrough
+ remains as the production reference. Both now state that **you need a second
+ phone number to test from** — Rumi answers *as* your number, so messaging it
+ from the same account looks exactly like a broken bot.
+- **The `/setup` skill** documents both front doors: the human wizard, and the
+ agent-driven "set me up" flow. The agent path calls the wizard's own modules
+ (validators, `.env` patcher, doctor probes, schema bootstrap) so the two cannot
+ drift, and the skill is explicit that `rumi setup`, `rumi pair` and
+ `rumi graduate` are interactive TTY programs an agent must not launch.
+- `rumi doctor` is channel-aware: it skips the Meta probe cleanly on a sandbox
+ channel and names the address when Redis does not answer.
+- `.env.template` opens by pointing at `./install.sh && rumi setup`.
+- **Test suite: 170 suites / 1997 tests**, up from 155/1724.
+
## [1.2.0] - 2026-07-29
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index dffadb7..6660650 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,10 +29,16 @@ CLAUDE.md (this file) → /CLAUDE.md (router) → .claude/skills//CLAUDE.md (router) → .claude/skills/Rumi
- The open-source AI Teaching Assistant that runs on WhatsApp
+ The open-source AI teaching assistant that lives on WhatsApp
You're not teaching alone.
@@ -32,25 +34,146 @@
---
-Rumi is an open-source AI teaching companion that runs on **WhatsApp** — giving teachers 24/7 access to classroom coaching, reading assessments, lesson plans, quizzes, and professional development, in their own language, on the device they already carry.
+**Rumi gives every teacher a coach in their pocket.** It runs entirely on WhatsApp — the app teachers already
+have — and offers classroom coaching on real lessons, reading assessments from a voice note, lesson plans,
+curriculum quizzes, and professional development, in the teacher's own language, 24 hours a day.
+
+It is built to be **cloned and run by anyone**. Bring your own API keys, point it at a WhatsApp number, and
+you have a teaching assistant for your schools — no commissioning, no vendor, no lock-in.
+
+
+
Meets teachers where they are
No app to install, no login to remember, no training day. If a teacher can send a WhatsApp message, they can use Rumi.
+
Coaches right after the lesson
A teacher records their class and gets a framework-scored report plus a reflective conversation — while the lesson is still fresh, not at an annual workshop.
+
Hears children read
A student reads aloud into a voice note; Rumi returns words-per-minute, accuracy, pronunciation and comprehension against grade benchmarks.
+
Ships with real content
890 curriculum videos, 10,929 QA-certified questions, 15,557 voice clips and 3,217 illustrations — free, CDN-hosted, one command to import.
+
Speaks their language
15 languages for chat, voice-note transcription and spoken replies — including a full Indian-language suite and Pakistan's regional languages.
+
Try it without Meta
Link your own WhatsApp with a QR code and start chatting in about fifteen minutes. No Business account, no app review, no waiting.
+
Set up by talking to it
The repo is agent-native: open it in Claude Code, Cursor or Codex and say "set me up". Or run one guided wizard that asks in plain language and checks every answer for you.
+
Your keys, your data
Your Supabase, your WhatsApp number, your model provider. Nothing routes through us. Apache-2.0.
+
+
+---
+
+## Quick Start
+
+```bash
+# 1. Fork this repo on GitHub, then clone YOUR fork
+git clone https://github.com/YOUR-ORG/rumi-platform.git
+cd rumi-platform
+
+# 2. Install — tools, dependencies, and the `rumi` command
+./install.sh
+
+# 3. Connect Rumi to your accounts — guided, one question at a time
+rumi setup
+
+# 4. Start it
+rumi start
+```
+
+Then message the number Rumi linked and send **`Hi`**.
+
+**About fifteen minutes**, most of it waiting for a Supabase project to start. `rumi setup` asks in plain
+language rather than by variable name ("where should Rumi keep its memory", not `SUPABASE_URL`), checks every
+value against the real service as you type it, creates the whole database for you, and saves each answer as
+it goes — so Ctrl+C is safe and running it again picks up where you stopped.
+
+### Important: you need a second phone number to test from
+
+> ⚠️ By default Rumi links **your own** WhatsApp account, the same way WhatsApp Web does — so Rumi *is* your
+> number. You cannot have a useful conversation with yourself, so you need a **different** number to message
+> it from.
+>
+> Any of these works: a spare SIM, an old phone, a work phone, a family member's phone, or a second WhatsApp
+> account logged into WhatsApp Web in your browser. Both numbers need WhatsApp installed and active.
+>
+> If you skip this you will pair successfully, send a message, and see nothing happen — not because anything
+> is broken, but because Rumi is on the other end of your own chat.
+
+### What you need
+
+| | Where to get it | What it's for | Cost |
+|---|---|---|---|
+| **Node.js 18+** and **git** | [nodejs.org](https://nodejs.org) | Running Rumi | free |
+| **A Supabase project** | [supabase.com](https://supabase.com) | Where Rumi remembers teachers, lessons and assessments | free tier is plenty |
+| **An OpenRouter key** | [openrouter.ai/keys](https://openrouter.ai/keys) | How Rumi thinks — one key, 500+ models | a few dollars goes a long way |
+| **A Redis address** | [Upstash](https://upstash.com) · [Railway](https://railway.app) · or local Docker | Conversations in progress, and background jobs | free tier is plenty |
+| **WhatsApp on your phone** | — | The number Rumi answers as | free |
+| **A second number with WhatsApp** | a spare SIM, old phone, or colleague | The number you message Rumi *from* — see above | free |
+
+The wizard offers to start Redis locally with Docker if you have it, so that row is often one keypress.
+**A Meta WhatsApp Business account is not required** to try Rumi — see [the two ways to run
+it](#the-two-ways-to-run-rumi).
+
+Optional feature keys (Soniox, ElevenLabs, Uplift, Gamma, Kie.ai, Azure, Mistral) unlock the features that
+use them, and only those. `rumi setup` offers them at the end and skipping is a real answer — every one can be
+added later by setting its key. Each is documented in [`.env.template`](.env.template).
+
+### The `rumi` command
+
+| Command | What it does |
+|---|---|
+| `rumi setup` | Connect Rumi to your accounts. **Start here.** `--reconfigure` re-asks everything. |
+| `rumi start` | Start Rumi |
+| `rumi status` | Is Rumi running, which WhatsApp number it answers as, what's switched on |
+| `rumi doctor` | Check every connection in detail, with where to get anything missing |
+| `rumi pair` | Link (or re-link) WhatsApp — sessions do expire |
+| `rumi graduate` | Move to an official WhatsApp Business number |
+
+If `install.sh` could not put `rumi` on your PATH, `node bin/rumi.js ` is identical.
+
+**Would rather not type it yourself?** Open the repo in a coding agent (Claude Code, Cursor, Codex) and say
+*"set me up"* — it walks you through the same sequence in conversation, following the
+[`/setup`](.claude/skills/setup/SKILL.md) skill. Setting up a real deployment (hosting, the Meta webhook,
+WhatsApp Flows, background workers), or prefer the manual route? **[SETUP.md](SETUP.md)** has the full
+walkthrough, including getting a WhatsApp number from scratch.
+
+---
+
+## The two ways to run Rumi
+
+Rumi's messaging channel is pluggable. You pick one during setup, in plain language, and you can change your
+mind later.
+
+| | **Just trying it out** | **Real deployment** |
+|---|---|---|
+| **What it is** | Rumi becomes a linked device on your own WhatsApp, exactly like WhatsApp Web | An official WhatsApp Business number through Meta's Cloud API |
+| **To get started** | Scan a QR code. ~2 minutes. | A Meta Business account, a verified number, and their app-review process |
+| **Good for** | Evaluating, demos, development | Schools, districts, anything with real teachers on it |
+| **Limits** | One personal account; WhatsApp may disconnect it. Tap-through forms, approved templates and picture-menu carousels are Meta-only — Rumi asks the same questions as an ordinary chat instead, so nothing is blocked, but it looks plainer. | None — the full experience |
+
+**Moving across is one command:** `rumi graduate`. Teachers, conversations and past assessments all carry
+over on their own, because Rumi identifies people by phone number rather than by channel. The one thing that
+cannot follow you is the number itself, so tell your testers to start a chat with the new one.
-It's built to be **cloned and run by anyone, anywhere**: set your own API keys, point it at your own WhatsApp number, and you have a teaching assistant for your schools — no commissioning, no vendor lock-in. And because the whole repo is **agent-native**, you can set it up and adapt it by talking to an AI coding agent — [see how ↓](#-built-to-be-run-by-an-ai-agent).
+Future channels (Slack, Telegram, …) plug into the same registry — see
+[docs/onboarding/sandbox-production-design.md](docs/onboarding/sandbox-production-design.md).
---
## Why Rumi Exists
-Across the world, **millions of teachers work in isolation** — in rural schools, multigrade classrooms, and under-resourced systems where instructional coaches simply don't exist. Traditional professional development reaches teachers once or twice a year at best. The gap between what teachers need and what the system provides is enormous.
+Across the world, **millions of teachers work in isolation** — in rural schools, multigrade classrooms, and
+under-resourced systems where instructional coaches simply don't exist. Traditional professional development
+reaches teachers once or twice a year at best. The gap between what teachers need and what the system
+provides is enormous.
-Rumi fills that gap. By meeting teachers on WhatsApp — the world's most widely used messaging app — Rumi provides instant coaching on real lessons, reading-fluency assessment, curriculum-aligned content, and multilingual support, all on the phone already in their pocket. The core insight: **the best time to coach a teacher is right after they teach**, and the best tool is the one they already have.
+Rumi fills that gap. By meeting teachers on WhatsApp — the world's most widely used messaging app — Rumi
+provides instant coaching on real lessons, reading-fluency assessment, curriculum-aligned content, and
+multilingual support, all on the phone already in their pocket. The core insight: **the best time to coach a
+teacher is right after they teach**, and the best tool is the one they already have.
-**Why open source?** Good teaching support shouldn't depend on which country or company you happen to work for. Any ministry, NGO, school network, or research team can stand up their own instance — adapt the frameworks to their curriculum, run it in their languages, keep their data in their own systems, and improve it for everyone.
+**Why open source?** Good teaching support shouldn't depend on which country or company you happen to work
+for. Any ministry, NGO, school network, or research team can stand up their own instance — adapt the
+frameworks to their curriculum, run it in their languages, keep their data in their own systems, and improve
+it for everyone.
---
## What Rumi Does
-Every feature lives on WhatsApp. Click any feature for its own page — what it is, how it works, and the API key(s) that switch it on.
+Every feature lives on WhatsApp. Click any feature for its own page — what it is, how it works, and the API
+key(s) that switch it on.
| Feature | What it does | Switches on when you set |
|---|---|---|
@@ -68,19 +191,31 @@ Every feature lives on WhatsApp. Click any feature for its own page — what it
| ✅ **[Attendance](docs/features/attendance.md)** | Voice- or tap-based attendance via WhatsApp Flows | _always on (core)_ |
| 🧮 **[Exam Checker](docs/features/exam-checker.md)** | Photograph answer sheets → vision OCR + AI grading | `MISTRAL_API_KEY` |
-> **No tiers, no toggles to hunt for.** Rumi gates features by **presence**: set a feature's API key and it switches on; leave it blank and it stays off cleanly — the bot never crashes over a missing key. Run **`npm run doctor`** anytime to see which features are live for your configuration.
+> **No tiers, no toggles to hunt for.** Rumi gates features by **presence**: set a feature's API key and it
+> switches on; leave it blank and it stays off cleanly — the bot never crashes over a missing key. Run
+> **`rumi status`** or **`rumi doctor`** anytime to see exactly which features are live and which key would
+> switch each remaining one on.
-**Go deeper:** browse the full **[feature library](docs/features/)** · understand how lesson plans get routed in **[LP_PATHS.md](docs/LP_PATHS.md)** · or look at a real **[sample coaching report (PDF)](docs/samples/coaching-report-sample.pdf)** rendered by the actual pipeline.
+**Go deeper:** browse the full **[feature library](docs/features/)** · understand how lesson plans get routed
+in **[LP_PATHS.md](docs/LP_PATHS.md)** · or look at a real **[sample coaching report
+(PDF)](docs/samples/coaching-report-sample.pdf)** rendered by the actual pipeline.
-Utility flows round it out — **settings** (language + framework), **status** (your active sessions), **edit-class** (roster), and a **student-video** library — each presence-gated on its WhatsApp Flow id.
+Utility flows round it out — **settings** (language + coaching framework), **status** (your active sessions),
+**edit-class** (roster), and a **student-video** library.
-### 🌐 Languages — now with a full Indian-language suite
+### 🌐 Languages
-Rumi meets teachers in their own language — for **text chat, voice-note transcription (STT), and spoken replies (TTS)** alike. Alongside English, Urdu, Arabic, Spanish, and Pakistan's regional languages, Rumi now ships a complete **Indian-language suite**:
+**15 languages**, for **text chat, voice-note transcription (STT), and spoken replies (TTS)** alike.
+Alongside English, Urdu, Arabic and Spanish, Rumi covers Pakistan's regional languages — Punjabi, Sindhi,
+Pashto and Balochi (via Meta's MMS-ASR) — Sri Lankan Tamil, and a complete **Indian-language suite**:
> **🇮🇳 हिन्दी Hindi · বাংলা Bengali · मराठी Marathi · తెలుగు Telugu · தமிழ் Tamil · ಕನ್ನಡ Kannada**
-Every one works end to end — teachers can chat and send voice notes in their language, get spoken and written replies back, and generate **lesson plans localized to Indian classrooms** (₹ money problems, locally familiar names and contexts). Pick a language anytime with **`/language`**, or just message Rumi in your own script. Which languages appear is driven per-region by config (`region_features`), so a deployment shows only what it serves.
+Every one works end to end — teachers chat and send voice notes in their language, get spoken and written
+replies back, and generate **lesson plans localized to their classrooms** (₹ money problems, locally familiar
+names and contexts). Pick a language anytime with **`/language`**, or just message Rumi in your own script.
+Which languages appear is driven per-region by config (`region_features`), so a deployment shows only what it
+serves.
---
@@ -97,99 +232,83 @@ Every one works end to end — teachers can chat and send voice notes in their l
▶ 68 seconds: a phone on a charpai, one message, and 890 lessons — watch the film
-Rumi now ships with a **real, complete content library — free and openly hosted**. Between 2015 and 2021, [Taleemabad](https://taleemabad.com)'s content team hand-wrote question banks, hand-drew the artwork, and studio-recorded voice clips for the Taleemabad Student App, used by hundreds of thousands of Pakistani children. That entire archive has been rescued, matched to its **890 curriculum videos** (Nursery–Grade 6, English + Urdu), QA-certified question by question, and rebuilt for WhatsApp:
+Rumi ships with a **real, complete content library — free and openly hosted**. Between 2015 and 2021,
+[Taleemabad](https://taleemabad.com)'s content team hand-wrote question banks, hand-drew the artwork, and
+studio-recorded voice clips for the Taleemabad Student App, used by hundreds of thousands of Pakistani
+children. That entire archive has been rescued, matched to its **890 curriculum videos** (Nursery–Grade 6,
+English + Urdu), QA-certified question by question, and rebuilt for WhatsApp:
-- A teacher sends **`/video`**, browses grade → subject → topic, and the video lands in her chat. Three seconds later she's offered its **quiz** — 15 questions with per-answer feedback, picture options she can actually tap, and phonics questions asked by voice note.
-- She can forward **one link** to her class WhatsApp group; every child plays in their own 1:1 chat, and she gets a **next-morning PDF** naming exactly what to reteach and why the class got it wrong.
-- **All media is served from a public CDN** — the videos, all 3,217 illustrations, and all 15,557 voice clips — so your clone needs *zero* content hosting. One command imports the whole library:
+- A teacher sends **`/video`**, browses grade → subject → topic, and the video lands in her chat. Three
+ seconds later she's offered its **quiz** — 15 questions with per-answer feedback, picture options she can
+ actually tap, and phonics questions asked by voice note.
+- She can forward **one link** to her class WhatsApp group; every child plays in their own 1:1 chat, and she
+ gets a **next-morning PDF** naming exactly what to reteach and why the class got it wrong.
+- **All media is served from a public CDN** — the videos, all 3,217 illustrations, and all 15,557 voice clips
+ — so your clone needs *zero* content hosting. One command imports the whole library:
```bash
node bot/scripts/setup/import-video-quiz-library.js --apply
```
-The library is Pakistani national-curriculum content, so the quiz feature is **region-gated to `pakistan`** out of the box (`DEFAULT_REGION=pakistan` in `.env` switches it on — the seed data does the rest). If you serve another curriculum, the gate is one row of config, and the full pipeline for building your own corpus is documented in **[docs/features/video-quizzes.md](docs/features/video-quizzes.md)**.
+The library is Pakistani national-curriculum content, so the quiz feature is **region-gated to `pakistan`**
+out of the box (`DEFAULT_REGION=pakistan` in `.env` switches it on — the seed data does the rest). If you
+serve another curriculum, the gate is one row of config, and the full pipeline for building your own corpus
+is documented in **[docs/features/video-quizzes.md](docs/features/video-quizzes.md)**.
---
## 🤖 Built to be run by an AI agent
-Rumi is **agent-native**: the repository is structured so a coding agent (Claude Code, Cursor, Codex, …) can read it, set it up, debug it, and customize it with you. This is what makes "clone and run it yourself" realistic for a small, non-specialist team.
-
-- **Progressive-disclosure context.** A root [`CLAUDE.md`](CLAUDE.md) (and [`AGENTS.md`](AGENTS.md)) orients the agent, then routes it down to folder guides ([`bot/CLAUDE.md`](bot/CLAUDE.md), [`infrastructure/CLAUDE.md`](infrastructure/CLAUDE.md)) and, on demand, to **16 operational skills** under [`.claude/skills/`](.claude/skills/) — coaching, reading-assessment, registration, lesson-plan routing, whatsapp-flows, debugging, logging, database analysis, QA, the pre-merge checklist, and more. The agent loads only what the task needs.
-- **Just ask.** Open the repo in your agent and say *"set me up"* — it reads the guides, walks the [`/setup`](.claude/skills/setup/SKILL.md) flow, runs `npm run doctor` and `npm run bootstrap:db`, and registers your WhatsApp Flows. Or say *"swap the coaching framework to TEACH"* and it follows the [customization guide](docs/agent-customization.md) to the exact files.
-- **Guard-railed for safety.** CI runs a secret scan (gitleaks) plus conformance guards that keep the schema, the docs, and the agent skills honest — so an agent's changes can't silently break a clone or leak a credential.
+Rumi is **agent-native**: the repository is structured so a coding agent (Claude Code, Cursor, Codex, …) can
+read it, set it up, debug it, and customize it with you. This is what makes "clone and run it yourself"
+realistic for a small, non-specialist team.
+
+- **Progressive-disclosure context.** A root [`CLAUDE.md`](CLAUDE.md) (and [`AGENTS.md`](AGENTS.md)) orients
+ the agent, then routes it down to folder guides ([`bot/CLAUDE.md`](bot/CLAUDE.md),
+ [`infrastructure/CLAUDE.md`](infrastructure/CLAUDE.md)) and, on demand, to **16 operational skills** under
+ [`.claude/skills/`](.claude/skills/) — coaching, reading-assessment, registration, lesson-plan routing,
+ whatsapp-flows, debugging, logging, database analysis, QA, the pre-merge checklist, and more. The agent
+ loads only what the task needs.
+- **Just ask.** Open the repo in your agent and say *"set me up"* — it asks you for each credential in
+ conversation, checks every one against the real service, creates your database, and hands you the two steps
+ that need human hands (pasting one SQL helper, scanning the WhatsApp QR). It uses the same modules the
+ `rumi setup` wizard does, so the two paths can't drift. Or say *"swap the coaching framework to TEACH"* and
+ it follows the [customization guide](docs/agent-customization.md) to the exact files.
+- **Guard-railed for safety.** CI runs a secret scan (gitleaks) plus conformance guards that keep the schema,
+ the docs, and the agent skills honest — so an agent's changes can't silently break a clone or leak a
+ credential.
Start at [`CLAUDE.md`](CLAUDE.md) → it points the way.
---
-## Quick Start
-
-```bash
-# 1. Fork this repo on GitHub, then clone YOUR fork
-git clone https://github.com/YOUR-ORG/rumi-platform.git
-cd rumi-platform
-
-# 2. Install dependencies
-npm install && cd bot && npm install && cd ..
-
-# 3. Configure environment — copy the template and fill the REQUIRED values
-# (8 required services: 7 ship as placeholders you must set; REDIS_URL defaults to a local instance.
-# `npm run doctor` lists exactly what's still missing.)
-cp .env.template .env
-# SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENROUTER_API_KEY, REDIS_URL,
-# WHATSAPP_TOKEN, PHONE_NUMBER_ID, WABA_ID, WEBHOOK_VERIFY_TOKEN
-# (each optional feature's keys are documented inline, grouped by feature)
-
-# 4. Check your configuration — pings every service you configured and prints
-# a green/red matrix of which features are live
-npm run doctor
-
-# 5. Set up the database (applies schema + RLS + seed to a fresh Supabase project)
-npm run bootstrap:db
-
-# 6. Deploy (Railway, Docker, or any Node host), point your WhatsApp webhook at
-# your deployment, then send "Hi" to your bot number.
-```
-
-The whole flow is designed to be driven by an **AI setup agent** — see [Built to be run by an AI agent](#-built-to-be-run-by-an-ai-agent) above, and **[SETUP.md](SETUP.md)** for the complete manual walkthrough (including getting a WhatsApp number from scratch).
-
-### What you need
-
-| Requirement | Where to get it | For |
-|---|---|---|
-| GitHub account | [github.com](https://github.com) | Fork the repo |
-| Node.js 18+ | [nodejs.org](https://nodejs.org) | Run the bot |
-| Supabase project | [supabase.com](https://supabase.com) (free tier works) | Database |
-| Redis | [Railway](https://railway.app) / [Upstash](https://upstash.com) | Sessions + job queue |
-| OpenRouter key | [openrouter.ai/keys](https://openrouter.ai/keys) | All AI text |
-| WhatsApp Business | [Meta Business Manager](https://business.facebook.com) | The channel |
-
-Optional feature keys (Soniox, ElevenLabs/Uplift, Gamma, Kie.ai, Azure, Mistral) are only needed for the features that use them — each is documented in [`.env.template`](.env.template).
-
----
-
## Architecture
```
rumi-platform/
+├── bin/rumi.js # The `rumi` CLI — setup, start, status, doctor, pair, graduate
+├── install.sh # One-time bootstrap: tools, dependencies, .env, the rumi command
├── bot/ # WhatsApp bot (Node.js + Express)
-│ ├── whatsapp-bot.js # Entry point — webhook, message routing
+│ ├── whatsapp-bot.js # Entry point — webhook, inbound routing, message dispatch
│ ├── shared/
│ │ ├── config/ # Presence-based feature gating, branding, languages, regions
-│ │ ├── services/ # LLM, coaching, reading, lesson plans, quiz, video, …
+│ │ ├── services/
+│ │ │ ├── messaging/ # Pluggable channel drivers (meta | baileys) + text-flow rendering
+│ │ │ ├── queue/ # Pluggable queue (sqs | bullmq)
+│ │ │ └── … # LLM, coaching, reading, lesson plans, quiz, video, …
│ │ ├── handlers/ # text / voice / image / flow / exam / attendance
+│ │ ├── routes/ # WhatsApp Flow endpoints (also drive the text fallbacks)
│ │ └── utils/ # Structured logging, correlation IDs, html-to-pdf
│ ├── workers/ # Async workers (coaching, video, lesson plans, quiz, exam, …)
-│ └── scripts/setup/ # doctor, flow registration, encryption, state
+│ └── scripts/setup/ # The wizard, doctor, pairing, flow registration, encryption
├── dashboard/ # Observability portal — analytics, health
├── portal/ # Teacher web portal (React)
├── infrastructure/
-│ └── supabase/ # SQL schema, RLS policies, seed data + bootstrap script
+│ └── supabase/ # SQL schema (76 tables), RLS policies, seed data + bootstrap
├── docs/ # Architecture, features, customization, cost, samples
└── .claude/ # Agent-native config — CLAUDE.md routers + 16 operational skills
```
@@ -198,21 +317,25 @@ rumi-platform/
```
Teacher on WhatsApp
- → Meta Cloud API → POST /webhook → Express handler
- → user lookup (Supabase) → language detection → feature routing
- → text | voice | image | flow handler
- → LLM (OpenRouter) → response
- → async job queue (Redis or SQS) → background workers → reports / media
- → delivered back to the teacher on WhatsApp
+ → Meta Cloud API (webhook) ·OR· linked-device socket (sandbox)
+ → one normalized inbound shape → message dispatch
+ → user lookup (Supabase) → language detection → feature routing
+ → text | voice | image | flow handler
+ → LLM (OpenRouter) → reply
+ → async job queue (Redis or SQS) → background workers → reports / media
+ → delivered back to the teacher on WhatsApp
```
-A correlation id threads each request across the webhook, the queue, and the workers, so any flow can be traced end to end. See [docs/architecture.md](docs/architecture.md) for the full picture, and [LP_PATHS.md](docs/LP_PATHS.md) for the lesson-plan routing in particular.
+Both channels converge on the same dispatch, so a feature is written once and works on either. A correlation
+id threads each request across the webhook, the queue, and the workers, so any flow can be traced end to end.
+See [docs/architecture.md](docs/architecture.md) for the full picture.
---
## Customization
-Rumi is meant to be **adapted to your context** — your curriculum, your frameworks, your languages, your brand.
+Rumi is meant to be **adapted to your context** — your curriculum, your frameworks, your languages, your
+brand.
**Quick (environment variables):**
@@ -223,7 +346,8 @@ SUPPORT_CONTACT=help@example.org
LLM_MODEL=anthropic/claude-sonnet-4
```
-**Deep (agent-first):** this repo is designed to be customized by AI-assisted IDEs. The [Agent Customization Guide](docs/agent-customization.md) maps each goal to exact files:
+**Deep (agent-first):** this repo is designed to be customized by AI-assisted IDEs. The [Agent Customization
+Guide](docs/agent-customization.md) maps each goal to exact files:
| I want to… | Guide |
|---|---|
@@ -242,9 +366,9 @@ LLM_MODEL=anthropic/claude-sonnet-4
|---|---|---|
| Runtime | Node.js 18+ | Server-side JavaScript |
| Web | Express.js | Webhook + API routes |
-| Messaging | WhatsApp Business Cloud API | Messages, media, interactive Flows |
+| Messaging | WhatsApp Cloud API **or** linked-device socket (pluggable via `CHANNEL_DRIVER`) | Messages, media, interactive Flows |
| AI / LLM | OpenRouter (500+ models) | Chat, analysis, content |
-| Database | Supabase (PostgreSQL) | Tables with Row-Level Security |
+| Database | Supabase (PostgreSQL) | 76 tables with Row-Level Security |
| Queue | Redis or AWS SQS (pluggable via `QUEUE_DRIVER`) | Transcription, reports, video, exams |
| Speech-to-Text | Soniox, Whisper, Modal MMS-ASR | Multilingual transcription |
| Text-to-Speech | ElevenLabs (+ Uplift for Urdu/regional) | Voice replies, reflective questions |
@@ -260,15 +384,16 @@ LLM_MODEL=anthropic/claude-sonnet-4
## Testing
```bash
-npm test # full suite (run via node tests/run.js)
+npm test # the full suite
npm run test:security # secret scan — no hardcoded credentials
npm run test:schema # database schema validation
npm run test:setup # setup tooling
-npm run doctor # live preflight: which services + features are configured
-npm run simulate # CLI simulator (test without WhatsApp)
+rumi doctor # live preflight: which services + features are configured
+npm run simulate # CLI simulator — try features without WhatsApp
```
-Every push and PR is gated by CI: an automated **secret scan** (gitleaks) plus conformance guards that verify the schema, the docs, the agent skills, and the link web all stay honest.
+Every push and PR is gated by CI: an automated **secret scan** (gitleaks) plus conformance guards that verify
+the schema, the docs, the agent skills, and the link web all stay honest.
---
@@ -276,11 +401,14 @@ Every push and PR is gated by CI: an automated **secret scan** (gitleaks) plus c
| Doc | What it covers |
|---|---|
-| [SETUP.md](SETUP.md) | Full setup, incl. getting a WhatsApp number from scratch |
+| [SETUP.md](SETUP.md) | Full setup — the two-command path, then the manual/production walkthrough |
| [docs/features/](docs/features/) | Per-feature deep dives (what / how / enable) — one page each |
+| [docs/onboarding/sandbox-production-design.md](docs/onboarding/sandbox-production-design.md) | How the channel drivers work, and how `rumi graduate` moves between them |
+| [docs/onboarding/whatsapp.md](docs/onboarding/whatsapp.md) | Getting a WhatsApp Business number, start to finish |
+| [docs/onboarding/api-keys.md](docs/onboarding/api-keys.md) | Every API key: what it unlocks and where to get it |
| [docs/LP_PATHS.md](docs/LP_PATHS.md) | How a lesson-plan request is routed (pre-generated vs Gamma vs photo) |
| [docs/architecture.md](docs/architecture.md) | System architecture & message flow |
-| [CLAUDE.md](CLAUDE.md) + [.claude/](.claude/) | **Agent-native** context: the progressive-disclosure routers + the 16 operational skills |
+| [CLAUDE.md](CLAUDE.md) + [.claude/](.claude/) | **Agent-native** context: the routers + the 16 operational skills |
| [docs/agent-customization.md](docs/agent-customization.md) | Agent-first deep customization (frameworks, languages, branding) |
| [docs/cost-guide.md](docs/cost-guide.md) | Monthly cost estimates — core baseline + per-feature add-ons |
| [docs/monitoring.md](docs/monitoring.md) | Observability & debugging |
@@ -292,18 +420,36 @@ Every push and PR is gated by CI: an automated **secret scan** (gitleaks) plus c
---
+## Troubleshooting
+
+| Symptom | What's happening |
+|---|---|
+| I paired successfully but Rumi never replies | You are almost certainly messaging from the same number Rumi is linked to. Use a [second number](#important-you-need-a-second-phone-number-to-test-from). |
+| `rumi doctor` says everything is "not configured" | Run it from the repo root, or make sure `.env` is there. `rumi status` will tell you what it can see. |
+| WhatsApp keeps syncing, or the session drops | Two processes must never share one WhatsApp session. Run `rumi status` to see what's holding it, stop that, then `rumi pair`. |
+| The bot won't start — "Missing REQUIRED env var(s)" | `rumi doctor` names each missing value and where to get it. |
+| A feature says it isn't available | It's presence-gated. `rumi status` lists which key switches it on. |
+
+More: [docs/monitoring.md](docs/monitoring.md) · or open the repo in your coding agent and paste the error.
+
+---
+
## Contributing
-Contributions are welcome. See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) for development setup, code style, testing, and PR guidelines.
+Contributions are welcome. See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) for development setup, code
+style, testing, and PR guidelines.
---
## About
-Rumi is built by [Taleemabad](https://taleemabad.com) and shared with the world as open source. The name comes from Jalaluddin Rumi, the 13th-century poet and teacher who believed that education is not the filling of a vessel but the kindling of a flame.
+Rumi is built by [Taleemabad](https://taleemabad.com) and shared with the world as open source. The name comes
+from Jalaluddin Rumi, the 13th-century poet and teacher who believed that education is not the filling of a
+vessel but the kindling of a flame.
**Website**: [hellorumi.ai](https://hellorumi.ai) · **Research**: [hellorumi.ai/research](https://hellorumi.ai/research)
## License
-Apache License 2.0 — see [LICENSE](LICENSE). You are free to use, modify, and distribute this software. We encourage contributing improvements back to the community.
+Apache License 2.0 — see [LICENSE](LICENSE). You are free to use, modify, and distribute this software. We
+encourage contributing improvements back to the community.
diff --git a/SETUP.md b/SETUP.md
index 6455d6f..a4bc70d 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -1,4 +1,75 @@
-# Rumi Platform - Setup Guide
+# Rumi Platform — Setup Guide
+
+Two commands take a fresh clone to a WhatsApp conversation with Rumi, in about fifteen minutes:
+
+```bash
+./install.sh # tools, dependencies, and the `rumi` command
+rumi setup # guided, one question at a time
+```
+
+`rumi setup` asks in plain language, checks every value against the real service as you enter it, and saves
+each answer as it goes — so Ctrl+C is safe, and running it again picks up where you stopped. Anything already
+working is not asked about twice.
+
+## What you'll need
+
+| For | Where to get it | Cost |
+|-----|-----------------|------|
+| **A database** — where Rumi remembers teachers, lessons and assessments | [supabase.com](https://supabase.com) | free tier is plenty |
+| **The AI** — one key, many models | [openrouter.ai/keys](https://openrouter.ai/keys) | a few dollars goes a long way |
+| **WhatsApp** | your own phone | — |
+
+Node.js 18+ and git need to be installed. Redis is required too, but the wizard offers to start one for you
+with Docker if you have it — otherwise paste any reachable address (Railway, Upstash, your own server).
+
+> **You do not need a Meta WhatsApp Business account to try Rumi.** The wizard's default links your own
+> WhatsApp the way WhatsApp Web does: scan a QR code and Rumi answers on your number. Nothing to register,
+> nothing to get approved. When you're ready for a real deployment, `rumi graduate` moves you to an official
+> WhatsApp Business number — teachers, conversations and past assessments all carry over, because Rumi
+> identifies people by phone number rather than by channel.
+
+## The `rumi` command
+
+| Command | What it does |
+|---------|--------------|
+| `rumi setup` | Connect Rumi to your accounts. Start here. `--reconfigure` re-asks everything. |
+| `rumi start` | Start the bot |
+| `rumi status` | Is Rumi running, which WhatsApp number it answers as, and what's switched on |
+| `rumi doctor` | Check every connection in detail, with where to get anything missing |
+| `rumi pair` | Link (or re-link) WhatsApp — sessions do expire |
+| `rumi graduate` | Move to an official WhatsApp Business number |
+
+If `install.sh` couldn't put `rumi` on your PATH (it needs npm permissions it may not have), use
+`node bin/rumi.js ` — identical in every way.
+
+## Then what?
+
+```bash
+rumi start
+```
+
+Message the number the wizard linked, from any phone, and try **Hi**, then `/menu`, `/reading test`, a voice
+note, or a photo of a worksheet.
+
+## What the wizard does, and what it can't
+
+It collects and live-checks your database, AI and Redis credentials; creates all 76 tables, the row-level
+security policies and the seed data; switches on any optional abilities you give it keys for; and links
+WhatsApp.
+
+One step it cannot do for you: Supabase offers no API for running arbitrary SQL, so the tiny `exec_sql`
+helper that the schema is applied through has to be pasted into the SQL editor once, by hand. The wizard
+detects this, prints the two lines, and links straight to the right page of your project.
+
+For a **production** deployment there is more to do than the wizard covers — hosting, the Meta webhook,
+registering WhatsApp Flows, and the background worker. That's what the rest of this guide is for.
+
+---
+
+# Manual setup and production reference
+
+Everything below can be done by hand instead of running the wizard, and steps 7 onward (deployment, webhook,
+Flows, workers) are needed for a real deployment either way.
## Prerequisites
@@ -9,7 +80,7 @@
| Supabase account | [supabase.com](https://supabase.com) (free tier works) |
| Railway account | [railway.app](https://railway.app) (for hosting + Redis) |
| OpenRouter API key | [openrouter.ai/keys](https://openrouter.ai/keys) (for LLM access) |
-| WhatsApp Business credentials | [Meta Business Manager](https://business.facebook.com) |
+| WhatsApp Business credentials | [Meta Business Manager](https://business.facebook.com) — production only |
> **New to any of these?** Two step-by-step guides walk you through the slow parts:
> - **[docs/onboarding/whatsapp.md](docs/onboarding/whatsapp.md)** — get a working WhatsApp connection in ~10 minutes (free test number), then go to production.
@@ -48,7 +119,7 @@ cd bot && npm install && cd ..
Then run `npm run bootstrap:db` — it applies all three SQL files in order. (If you skip the helper, the command stops with the exact SQL above.)
**Option B — manual paste:** In the SQL Editor, run these three files in order:
- - `infrastructure/supabase/00_complete-schema.sql` — all 73 tables, 40 functions, 29 triggers, 200+ indexes
+ - `infrastructure/supabase/00_complete-schema.sql` — all 76 tables, 40 functions, 29 triggers, 200+ indexes
- `infrastructure/supabase/01_rls-policies.sql` — enables Row Level Security on all tables
- `infrastructure/supabase/02_seed-data.sql` — adds reading assessment benchmarks
4. **Verify** by running `infrastructure/supabase/verify-schema.sql` — all checks should show PASS
diff --git a/bin/rumi.js b/bin/rumi.js
new file mode 100755
index 0000000..0911d5e
--- /dev/null
+++ b/bin/rumi.js
@@ -0,0 +1,193 @@
+#!/usr/bin/env node
+/**
+ * rumi — one command for everything an operator does outside the bot itself.
+ *
+ * rumi setup connect Rumi to your accounts (start here)
+ * rumi status is Rumi running, and what is switched on
+ * rumi doctor check every connection in detail
+ * rumi pair link (or re-link) WhatsApp
+ * rumi graduate move to an official WhatsApp Business number
+ *
+ * Repo-local by design (`bin/rumi.js`, not a published package): a Rumi
+ * deployment is a clone or a fork, so there is no single global install to
+ * distribute. install.sh offers to `npm link` it so a bare `rumi` works;
+ * otherwise `node bin/rumi.js ` is equivalent.
+ *
+ * Command bodies live in bot/scripts/setup/ and are required lazily — starting
+ * a wizard should not pay for loading the doctor's probes, and `rumi --help`
+ * should not load anything at all.
+ *
+ * @module rumi
+ */
+
+// A `rumi` command is a conversation with a person, so its output must stay
+// human-readable. bot/shared/utils/structured-logger.js replaces console.* with
+// JSON logging for the server, and any module that reaches it (the WhatsApp
+// connection does) would take this command's output with it — a QR code and a
+// wizard, rendered as log records. Set before the first require, since the
+// override happens at import time.
+process.env.RUMI_CLI = '1';
+
+const path = require('path');
+
+const SCRIPTS_DIR = path.resolve(__dirname, '../bot/scripts/setup');
+// dotenv is a dependency of bot/package.json, not the repo root's — and this
+// file lives at the repo root, so a bare require('dotenv') fails to resolve
+// here. It used to fail *silently* (swallowed by a try/catch), so .env never
+// loaded and a fully-configured deployment reported every variable missing.
+// Resolve it from bot/node_modules, the same place every bot/scripts/setup
+// module already loads it from successfully.
+const BOT_DIR = path.resolve(SCRIPTS_DIR, '..', '..');
+
+const REPO_ROOT = path.resolve(BOT_DIR, '..');
+
+function loadEnv() {
+ try {
+ // Anchored to the repo, not the working directory. Run from bot/, a bare
+ // config() looked for bot/.env, loaded nothing, and every command reported
+ // a fully-configured deployment as "not configured".
+ require(path.join(BOT_DIR, 'node_modules', 'dotenv'))
+ .config({ path: path.join(REPO_ROOT, '.env'), quiet: true });
+ } catch {
+ // Not installed yet (someone running `rumi` before install.sh finished) —
+ // the commands below all read process.env, which is still valid, just bare.
+ }
+}
+
+const ui = () => require(path.join(SCRIPTS_DIR, 'ui'));
+
+const COMMANDS = {
+ start: {
+ summary: 'Start Rumi (the bot itself)',
+ run: () => new Promise((resolve) => {
+ // Runs from the repo root so .env and the WhatsApp session resolve the
+ // same way whatever directory you called `rumi` from — the reason this
+ // command exists rather than `cd bot && npm start`.
+ const child = require('child_process').spawn(
+ process.execPath, [path.join(BOT_DIR, 'whatsapp-bot.js')],
+ {
+ cwd: REPO_ROOT,
+ stdio: 'inherit',
+ // The bot is a server and wants its structured JSON logging; RUMI_CLI
+ // is this launcher's business, not the child's.
+ env: Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'RUMI_CLI')),
+ },
+ );
+ // Forward signals to the bot. `stdio: 'inherit'` means an interactive
+ // Ctrl+C reaches the whole process group anyway, but `kill `
+ // — from a script, a supervisor, or a stop command — only signals this
+ // launcher, which would leave the bot orphaned and still holding the
+ // WhatsApp session lock. The next `rumi start` then refuses to attach,
+ // correctly but confusingly, blaming a pid nobody can see.
+ const forward = (signal) => () => { if (child.exitCode === null) child.kill(signal); };
+ const onInt = forward('SIGINT');
+ const onTerm = forward('SIGTERM');
+ process.on('SIGINT', onInt);
+ process.on('SIGTERM', onTerm);
+
+ child.on('exit', (code, signal) => {
+ process.off('SIGINT', onInt);
+ process.off('SIGTERM', onTerm);
+ process.exitCode = signal ? 1 : (code || 0);
+ resolve();
+ });
+ }),
+ },
+ setup: {
+ summary: 'Connect Rumi to your accounts — start here',
+ run: () => require(path.join(SCRIPTS_DIR, 'interactive-setup')).main(),
+ },
+ status: {
+ summary: 'Is Rumi running, and what is switched on',
+ run: () => require(path.join(SCRIPTS_DIR, 'status')).main(),
+ },
+ doctor: {
+ summary: 'Check every connection in detail',
+ run: async () => {
+ const { runDoctor, formatReport } = require(path.join(SCRIPTS_DIR, 'doctor'));
+ loadEnv();
+ const result = await runDoctor({});
+ console.log(formatReport(result));
+ process.exitCode = result.ok ? 0 : 1;
+ },
+ },
+ pair: {
+ summary: 'Link (or re-link) WhatsApp',
+ run: () => require(path.join(SCRIPTS_DIR, 'baileys-pair')).main(),
+ },
+ graduate: {
+ summary: 'Move to an official WhatsApp Business number',
+ run: () => require(path.join(SCRIPTS_DIR, 'graduate')).main(),
+ },
+};
+
+const OPTIONS = [
+ ['--reconfigure', 'setup: ask about everything again, including what already works'],
+ ['--to=', 'graduate: which channel to move to (defaults to meta)'],
+];
+
+function printUsage() {
+ const u = ui();
+ console.log(u.logo('An AI teaching companion that lives in WhatsApp'));
+ console.log(` ${u.bold('Usage')}`);
+ console.log(` rumi `);
+ console.log('');
+ console.log(` ${u.bold('Commands')}`);
+ console.log(u.table(
+ Object.entries(COMMANDS).map(([name, cmd]) => [name, u.dim(cmd.summary)]),
+ { labelRole: 'brandHi', indent: 4 },
+ ));
+ console.log('');
+ console.log(` ${u.bold('Options')}`);
+ console.log(u.table(
+ OPTIONS.map(([flag, description]) => [flag, u.dim(description)]),
+ { labelRole: 'accent', indent: 4 },
+ ));
+ console.log('');
+ console.log(u.aside('New here? Run `rumi setup` — it takes about fifteen minutes and explains each step as it goes.'));
+ console.log('');
+}
+
+function printVersion() {
+ const { version } = require('../package.json');
+ console.log(`rumi ${version}`);
+}
+
+async function main() {
+ const [, , command] = process.argv;
+
+ if (command === '--version' || command === '-v') {
+ printVersion();
+ return;
+ }
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
+ printUsage();
+ process.exitCode = command ? 0 : 1;
+ return;
+ }
+
+ const handler = COMMANDS[command];
+ if (!handler) {
+ console.log(ui().fail(`Unknown command: "${command}"`));
+ console.log('');
+ printUsage();
+ process.exitCode = 1;
+ return;
+ }
+
+ await handler.run();
+}
+
+if (require.main === module) {
+ // Exit explicitly once the command is done. A probe that leaves a socket open
+ // (Redis, WhatsApp) would otherwise hold the event loop and the command would
+ // appear to hang after printing its result.
+ main()
+ .then(() => process.exit(process.exitCode || 0))
+ .catch((err) => {
+ console.error(ui().fail(`rumi ${process.argv[2] || ''}: ${err.message}`));
+ process.exit(1);
+ });
+}
+
+module.exports = { main, COMMANDS, printUsage };
diff --git a/bot/CLAUDE.md b/bot/CLAUDE.md
index c449f3e..41df1cd 100644
--- a/bot/CLAUDE.md
+++ b/bot/CLAUDE.md
@@ -9,13 +9,14 @@
|------|--------------|
| `whatsapp-bot.js` | Express webhook, interactive-button router, message dispatch |
| `shared/handlers/` | Message handlers (text, voice, image, flow-response, …) — 10 files |
-| `shared/services/` | Domain services (49) — AI, coaching, reading, quiz, pic-to-LP, whatsapp, R2, … |
+| `shared/services/` | Domain services (45) — AI, coaching, reading, quiz, pic-to-LP, whatsapp, R2, … |
| `shared/services/queue/` | **Pluggable queue** — `index.js` selects driver by `QUEUE_DRIVER` (sqs\|bullmq) |
+| `shared/services/messaging/` | **Pluggable channel** — `index.js` selects driver by `CHANNEL_DRIVER` (meta\|baileys); `inbound/` normalizes either into one shape; `text-flow*.js` renders Meta Flows as chat |
| `shared/routes/` | WhatsApp Flow endpoints (registration, attendance, settings, status, …) |
| `shared/config/` | `feature-availability.js` (presence gating), `branding.js`, `region-config.js` |
| `shared/utils/` | logger, structured-logger (correlation IDs), constants, phone-validation |
-| `workers/` | Background job workers (10) — `sqs-worker.js` is the poll loop; one handler per job type |
-| `scripts/` | CLI simulator, validators, setup `doctor.js` |
+| `workers/` | Background job workers (9) — `sqs-worker.js` is the poll loop; one handler per job type |
+| `scripts/` | CLI simulator, validators, and `scripts/setup/` — the `rumi` wizard, doctor, pairing, flow registration |
## Things to know before editing
diff --git a/bot/package-lock.json b/bot/package-lock.json
index b7c7f3e..978db77 100644
--- a/bot/package-lock.json
+++ b/bot/package-lock.json
@@ -18,6 +18,7 @@
"@supabase/supabase-js": "^2.78.0",
"aws-sdk": "^2.1691.0",
"axios": "^1.13.1",
+ "baileys": "^7.0.0-rc14",
"bcryptjs": "^3.0.3",
"bullmq": "^5.67.1",
"dotenv": "^17.2.3",
@@ -39,6 +40,7 @@
"pdfkit": "^0.17.2",
"pino": "^10.1.0",
"playwright-core": "^1.60.0",
+ "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.4",
"uuid": "^9.0.1"
},
@@ -655,7 +657,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -1141,6 +1142,52 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@borewit/text-codec": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
+ "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@cacheable/memory": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+ "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.5.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/node-cache": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
+ "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.3.1",
+ "hookified": "^1.14.0",
+ "keyv": "^5.5.5"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+ "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.1",
+ "keyv": "^5.6.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -1148,7 +1195,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -1160,7 +1206,6 @@
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1469,6 +1514,21 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@hapi/boom": {
+ "version": "9.1.4",
+ "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
+ "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+ "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -2509,6 +2569,28 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "license": "MIT"
+ },
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -2730,6 +2812,63 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
@@ -2970,6 +3109,29 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tokenizer/inflate": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
+ "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "token-types": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -3661,6 +3823,15 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
+ "node_modules/async-mutex": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
+ "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3835,6 +4006,95 @@
"@babel/core": "^7.11.0 || ^8.0.0-beta.1"
}
},
+ "node_modules/baileys": {
+ "version": "7.0.0-rc14",
+ "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc14.tgz",
+ "integrity": "sha512-pewtrljhWx5JTUBvvkXZz1fL3JPiwzjBsnhx/DWf2LWBx1cZNH7J/sF22xDehba5mySWUQU4a1Pwt7lWARUxaA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/node-cache": "^1.4.0",
+ "@hapi/boom": "^9.1.3",
+ "async-mutex": "^0.5.0",
+ "libsignal": "^6.0.0",
+ "lru-cache": "^11.1.0",
+ "music-metadata": "^11.12.3",
+ "p-queue": "^9.0.0",
+ "pino": "^9.6",
+ "protobufjs": "^7.5.6",
+ "whatsapp-rust-bridge": "0.5.4",
+ "ws": "^8.13.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "audio-decode": "^2.1.3",
+ "jimp": "^1.6.1",
+ "link-preview-js": "^3.0.0",
+ "sharp": "*"
+ },
+ "peerDependenciesMeta": {
+ "audio-decode": {
+ "optional": true
+ },
+ "jimp": {
+ "optional": true
+ },
+ "link-preview-js": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/baileys/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/baileys/node_modules/pino": {
+ "version": "9.14.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+ "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/baileys/node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/baileys/node_modules/thread-stream": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
+ "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4035,7 +4295,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -4174,6 +4433,19 @@
"integrity": "sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ==",
"license": "(Apache-2.0 AND MIT)"
},
+ "node_modules/cacheable": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+ "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.2.0",
+ "@cacheable/utils": "^2.5.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.10.1"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -4327,8 +4599,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz",
"integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==",
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/chartjs-node-canvas": {
"version": "4.1.6",
@@ -4683,6 +4954,12 @@
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
+ "node_modules/curve25519-js": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
+ "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
+ "license": "MIT"
+ },
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -5100,6 +5377,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
"node_modules/events": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
@@ -5206,7 +5489,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5394,6 +5676,24 @@
"bser": "2.1.1"
}
},
+ "node_modules/file-type": {
+ "version": "21.3.4",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
+ "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/inflate": "^0.4.1",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.1",
+ "uint8array-extras": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -5913,6 +6213,18 @@
"license": "ISC",
"optional": true
},
+ "node_modules/hashery": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+ "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -5932,6 +6244,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "license": "MIT"
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -7183,6 +7501,15 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -7235,6 +7562,15 @@
"node": ">=6"
}
},
+ "node_modules/libsignal": {
+ "version": "6.0.0",
+ "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7",
+ "license": "GPL-3.0",
+ "dependencies": {
+ "curve25519-js": "^0.0.4",
+ "protobufjs": "^7.5.5"
+ }
+ },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -7374,6 +7710,12 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT"
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
@@ -7697,6 +8039,63 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
+ "node_modules/music-metadata": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.14.0.tgz",
+ "integrity": "sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://buymeacoffee.com/borewit"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.2",
+ "@tokenizer/token": "^0.3.0",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "file-type": "^21.3.4",
+ "media-typer": "^2.0.0",
+ "strtok3": "^10.3.5",
+ "token-types": "^6.1.2",
+ "uint8array-extras": "^1.5.0",
+ "win-guid": "^0.2.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/music-metadata/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/music-metadata/node_modules/media-typer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
+ "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/nan": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz",
@@ -8028,6 +8427,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-queue": {
+ "version": "9.3.3",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
+ "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.4",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -8429,6 +8856,29 @@
],
"license": "MIT"
},
+ "node_modules/protobufjs": {
+ "version": "7.6.5",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
+ "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -8485,6 +8935,32 @@
],
"license": "MIT"
},
+ "node_modules/qified": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+ "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qified/node_modules/hookified": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+ "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+ "license": "MIT"
+ },
+ "node_modules/qrcode-terminal": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
+ "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
+ "bin": {
+ "qrcode-terminal": "bin/qrcode-terminal.js"
+ }
+ },
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -9314,6 +9790,22 @@
],
"license": "MIT"
},
+ "node_modules/strtok3": {
+ "version": "10.3.5",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
+ "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -9495,6 +9987,44 @@
"node": ">=0.6"
}
},
+ "node_modules/token-types": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
+ "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/token-types/node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -9596,6 +10126,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/uint8array-extras": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+ "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
@@ -9838,6 +10380,12 @@
"license": "BSD-2-Clause",
"optional": true
},
+ "node_modules/whatsapp-rust-bridge": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz",
+ "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==",
+ "license": "MIT"
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -9892,6 +10440,12 @@
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
+ "node_modules/win-guid": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
+ "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
+ "license": "MIT"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/bot/package.json b/bot/package.json
index 1c23fe5..79a3401 100644
--- a/bot/package.json
+++ b/bot/package.json
@@ -1,6 +1,6 @@
{
"name": "rumi-bot",
- "version": "1.1.0",
+ "version": "2.0.0",
"description": "Rumi - AI Teaching Assistant for WhatsApp",
"main": "whatsapp-bot.js",
"scripts": {
@@ -35,6 +35,7 @@
"@supabase/supabase-js": "^2.78.0",
"aws-sdk": "^2.1691.0",
"axios": "^1.13.1",
+ "baileys": "^7.0.0-rc14",
"bcryptjs": "^3.0.3",
"bullmq": "^5.67.1",
"dotenv": "^17.2.3",
@@ -56,6 +57,7 @@
"pdfkit": "^0.17.2",
"pino": "^10.1.0",
"playwright-core": "^1.60.0",
+ "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.4",
"uuid": "^9.0.1"
},
diff --git a/bot/scripts/setup/baileys-pair.js b/bot/scripts/setup/baileys-pair.js
new file mode 100755
index 0000000..ce8769e
--- /dev/null
+++ b/bot/scripts/setup/baileys-pair.js
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+/**
+ * baileys-pair.js — `rumi pair` (also `npm run pair:baileys`).
+ *
+ * Links, or re-links, the sandbox WhatsApp channel by QR. `rumi setup` runs the
+ * same pairing as its final step; this is the standalone way back in when a
+ * session drops — which it will, since WhatsApp expires linked devices.
+ *
+ * The pairing itself lives in link-whatsapp.js, shared with the wizard, so
+ * there is only ever one code path writing the WhatsApp session. Two writers is
+ * how a session gets invalidated, and the recovery for that is manual.
+ *
+ * @module baileys-pair
+ */
+
+// A `rumi` command is a conversation with a person, so its output must stay
+// human-readable. bot/shared/utils/structured-logger.js replaces console.* with
+// JSON logging for the server, and any module that reaches it (the WhatsApp
+// connection does) would take this command's output with it — a QR code and a
+// wizard, rendered as log records. Set before the first require, since the
+// override happens at import time.
+process.env.RUMI_CLI = '1';
+
+const ui = require('./ui');
+
+async function main() {
+ try {
+ const path = require('path');
+ require('dotenv').config({ path: path.resolve(__dirname, '../../..', '.env'), quiet: true });
+ } catch { /* dotenv optional */ }
+
+ const connection = require('../../shared/services/messaging/baileys-connection');
+ const { linkWhatsApp, releaseWhatsApp } = require('./link-whatsapp');
+
+ console.log(ui.logo('Linking WhatsApp'));
+ console.log(ui.say('On your phone: WhatsApp → Settings → Linked devices → Link a device, then point it at the code that appears below.'));
+ console.log(ui.aside(`Session folder: ${connection.authDir()} — treat it like a password; it grants access to the account.`));
+ console.log('');
+
+ // Deliberately not a spinner: the connection module renders the QR straight to
+ // stdout, and a spinner redrawing itself every 90ms would overwrite the last
+ // row of the code — which is the row a phone camera needs most.
+ console.log(ui.dim(' Connecting to WhatsApp…'));
+ const result = await linkWhatsApp();
+ await releaseWhatsApp();
+
+ if (result.ok) {
+ console.log('');
+ console.log(ui.ok(`Linked${result.number ? ` as ${ui.bold(`+${result.number}`)}` : ''}`));
+ console.log(ui.say('Start Rumi with `rumi start`, then message that number from any phone.'));
+ process.exit(0);
+ }
+
+ console.log('');
+ if (result.reason === 'busy') {
+ console.log(ui.fail('Rumi is already running, and two processes cannot share one WhatsApp session.'));
+ console.log(ui.aside('Stop the running one first, then try again. `rumi status` shows what is holding it.'));
+ } else if (result.reason === 'logged-out') {
+ console.log(ui.fail('WhatsApp rejected the session.'));
+ console.log(ui.aside(`Delete ${connection.authDir()} and run this again to link from scratch.`));
+ } else if (result.reason === 'timeout') {
+ console.log(ui.fail('Nothing was scanned in time.'));
+ console.log(ui.aside('The code expires every few seconds and refreshes on its own — have Linked devices open on your phone before running this, then try again.'));
+ } else {
+ console.log(ui.fail(`Could not link: ${result.detail || 'unknown error'}`));
+ }
+ process.exit(1);
+}
+
+if (require.main === module) main();
+
+module.exports = { main };
diff --git a/bot/scripts/setup/db-setup.js b/bot/scripts/setup/db-setup.js
new file mode 100644
index 0000000..9081ef0
--- /dev/null
+++ b/bot/scripts/setup/db-setup.js
@@ -0,0 +1,133 @@
+/**
+ * db-setup.js — the database half of `rumi setup`.
+ *
+ * Creating Rumi's tables is the one setup step that cannot be fully automated,
+ * and the reason is worth stating plainly: Supabase exposes no API for running
+ * arbitrary SQL, so the schema is applied through an `exec_sql` function which
+ * itself has to be created by hand once, in the SQL editor. Every guide that
+ * skips this detail sends the reader to an opaque 404.
+ *
+ * So this module's job is to know exactly which of the three states a database
+ * is in — already set up, missing the helper, or ready to receive the schema —
+ * and let the wizard say the one true sentence for that state.
+ *
+ * @module db-setup
+ */
+
+const path = require('path');
+
+const SCHEMA_DIR = path.resolve(__dirname, '../../../infrastructure/supabase');
+
+/**
+ * The one-time helper. `exec_sql` is what every schema/migration script in this
+ * repo runs SQL through; a brand-new project has no such function.
+ */
+const EXEC_SQL_DEFINITION = [
+ 'create or replace function exec_sql(query text)',
+ 'returns void as $$ begin execute query; end; $$ language plpgsql;',
+];
+
+/**
+ * A table from the top of 00_complete-schema.sql, used as the "has the schema
+ * been applied?" sentinel. PostgREST answers 404/PGRST205 for a table it has
+ * never seen, which is a cleaner signal than counting rows.
+ */
+const SENTINEL_TABLE = 'users';
+
+function headersFor(env) {
+ return {
+ apikey: env.SUPABASE_SERVICE_ROLE_KEY,
+ Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
+ 'Content-Type': 'application/json',
+ };
+}
+
+/**
+ * Which of the three states is this database in?
+ *
+ * @param {{SUPABASE_URL: string, SUPABASE_SERVICE_ROLE_KEY: string}} env
+ * @param {typeof fetch} [fetchImpl]
+ * @returns {Promise<{state: 'ready'|'needs-helper'|'needs-schema'|'unreachable', detail: string}>}
+ */
+async function inspectDatabase(env, fetchImpl = fetch) {
+ let schemaApplied;
+ try {
+ const res = await fetchImpl(
+ `${env.SUPABASE_URL}/rest/v1/${SENTINEL_TABLE}?select=id&limit=1`,
+ { headers: headersFor(env) },
+ );
+ if (res.status === 401 || res.status === 403) {
+ return { state: 'unreachable', detail: `the key was rejected (HTTP ${res.status})` };
+ }
+ schemaApplied = res.ok;
+ } catch (err) {
+ return { state: 'unreachable', detail: err.message };
+ }
+
+ if (schemaApplied) return { state: 'ready', detail: `the "${SENTINEL_TABLE}" table is already there` };
+
+ // No schema yet — so can we apply it, or does the helper have to come first?
+ const helper = await hasExecSql(env, fetchImpl);
+ return helper.present
+ ? { state: 'needs-schema', detail: 'no tables yet, but the SQL helper is in place' }
+ : { state: 'needs-helper', detail: helper.detail };
+}
+
+/**
+ * Is the `exec_sql` helper callable? Probed with a harmless statement, because
+ * "the function exists" and "the function works" are different claims and only
+ * the second one matters.
+ *
+ * @returns {Promise<{present: boolean, detail: string}>}
+ */
+async function hasExecSql(env, fetchImpl = fetch) {
+ try {
+ const res = await fetchImpl(`${env.SUPABASE_URL}/rest/v1/rpc/exec_sql`, {
+ method: 'POST',
+ headers: headersFor(env),
+ body: JSON.stringify({ query: 'select 1' }),
+ });
+ if (res.ok) return { present: true, detail: 'exec_sql answered' };
+ const body = await res.text().catch(() => '');
+ return { present: false, detail: `exec_sql is missing (HTTP ${res.status})`, raw: body };
+ } catch (err) {
+ return { present: false, detail: err.message };
+ }
+}
+
+/**
+ * The Supabase SQL-editor URL for a project, derived from its API URL — so the
+ * wizard can hand over a link that lands on the right page of the right
+ * project instead of "go and find the SQL editor". Returns null for anything
+ * that isn't a hosted supabase.co project (self-hosted, local).
+ *
+ * @param {string} supabaseUrl
+ * @returns {string|null}
+ */
+function sqlEditorUrl(supabaseUrl) {
+ const match = /^https?:\/\/([a-z0-9-]+)\.supabase\.(co|in)/i.exec(String(supabaseUrl || ''));
+ return match ? `https://supabase.com/dashboard/project/${match[1]}/sql/new` : null;
+}
+
+/**
+ * Applies schema → RLS → seed via the existing bootstrapper, which is
+ * idempotent, so re-running on a half-applied database is safe.
+ *
+ * @param {object} env
+ * @returns {Promise<{ok: boolean, applied: string[], errors: Array<{file: string, error: string}>}>}
+ */
+async function applySchema(env) {
+ const { DatabaseBootstrapper } = require('../../../infrastructure/scripts/bootstrap-db');
+ const bootstrapper = new DatabaseBootstrapper({
+ supabaseUrl: env.SUPABASE_URL,
+ supabaseKey: env.SUPABASE_SERVICE_ROLE_KEY,
+ schemaDir: SCHEMA_DIR,
+ });
+ const result = await bootstrapper.bootstrap();
+ return { ok: result.errors.length === 0, ...result };
+}
+
+module.exports = {
+ EXEC_SQL_DEFINITION, SENTINEL_TABLE, SCHEMA_DIR,
+ inspectDatabase, hasExecSql, sqlEditorUrl, applySchema,
+};
diff --git a/bot/scripts/setup/doctor.js b/bot/scripts/setup/doctor.js
index 307c064..e97bc3c 100644
--- a/bot/scripts/setup/doctor.js
+++ b/bot/scripts/setup/doctor.js
@@ -24,7 +24,10 @@
// Single source of truth: bot/shared/config/feature-availability.js
const fs = require('fs');
const path = require('path');
-const { REQUIRED_VARS, FEATURES, isSet } = require('../../shared/config/feature-availability');
+const {
+ REQUIRED_VARS, CHANNEL_REQUIRED_VARS, FEATURES, isSet, requiredVarsFor, resolveChannelDriver,
+} = require('../../shared/config/feature-availability');
+const { DRIVERS, isProductionTier } = require('../../shared/services/messaging/channel-registry');
const { FLOW_CONFIGS } = require('./flow-configs');
// ── "Where do I get this?" hints ─────────────────────────────────────────────
@@ -80,11 +83,21 @@ function analyzeFlows(state) {
/**
* @param {object} env Usually process.env.
- * @returns {{ requiredPresent, missingRequired, features }}
+ * @returns {{ requiredPresent, missingRequired, features, channel, channelDriverTypo }}
*/
function analyzeEnv(env) {
- const requiredPresent = REQUIRED_VARS.filter((k) => isSet(env[k]));
- const missingRequired = REQUIRED_VARS.filter((k) => !isSet(env[k]));
+ const channel = resolveChannelDriver(env);
+ // If CHANNEL_DRIVER was set explicitly but didn't name a known driver,
+ // surface that as its own warning — resolveChannelDriver() already fell
+ // back to the default, so this is the one place left that would otherwise
+ // silently hide a typo from the report.
+ const rawChannelDriver = (env.CHANNEL_DRIVER || '').trim().toLowerCase();
+ const channelDriverTypo = rawChannelDriver && !Object.prototype.hasOwnProperty.call(DRIVERS, rawChannelDriver)
+ ? rawChannelDriver
+ : null;
+ const requiredVars = requiredVarsFor(env);
+ const requiredPresent = requiredVars.filter((k) => isSet(env[k]));
+ const missingRequired = requiredVars.filter((k) => !isSet(env[k]));
const features = FEATURES.map((f) => {
// Features may declare keys two ways:
@@ -111,7 +124,9 @@ function analyzeEnv(env) {
return { name: f.name, requiredKeys: keys, missingKeys, available, probe: f.probe || null, notes: f.notes || null };
});
- return { requiredPresent, missingRequired, features };
+ return {
+ requiredPresent, missingRequired, features, channel, channelDriverTypo,
+ };
}
// ── Default live probes (network). Each returns { ok, detail }. ──────────────
@@ -125,10 +140,46 @@ const defaultProbes = {
});
return { ok: res.status < 500, detail: `HTTP ${res.status}` };
},
+ /**
+ * Checks the key AND that it can actually pay for a call.
+ *
+ * A valid-but-broke key answers HTTP 200 here while every real feature fails:
+ * a fresh OpenRouter account has no purchased credits, and once the free
+ * allowance is spent the API returns "402 … you requested up to 16384 tokens,
+ * but can only afford 2236". Reporting a green tick for that is the worst kind
+ * of preflight — it sends the operator looking for a bug in the bot. Found
+ * live on exactly this setup: chat replies worked, quiz generation did not.
+ */
async openrouter(env) {
- const res = await fetch('https://openrouter.ai/api/v1/key', {
- headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}` },
- });
+ const headers = { Authorization: `Bearer ${env.OPENROUTER_API_KEY}` };
+ const res = await fetch('https://openrouter.ai/api/v1/key', { headers });
+ if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` };
+
+ // Credits are a separate endpoint; a failure to read them must not turn a
+ // working key red, so this only ever downgrades on a definite answer.
+ try {
+ const creditRes = await fetch('https://openrouter.ai/api/v1/credits', { headers });
+ if (creditRes.ok) {
+ const { data } = await creditRes.json();
+ const granted = Number(data?.total_credits);
+ const used = Number(data?.total_usage);
+ if (Number.isFinite(granted) && Number.isFinite(used)) {
+ const remaining = granted - used;
+ if (remaining <= 0) {
+ return {
+ ok: false,
+ detail: granted === 0
+ ? 'key valid, but the account has no credits — add some at openrouter.ai/settings/credits'
+ : `key valid, but credits are exhausted ($${granted.toFixed(2)} granted, $${used.toFixed(2)} used) — top up at openrouter.ai/settings/credits`,
+ };
+ }
+ return { ok: true, detail: `HTTP ${res.status} · $${remaining.toFixed(2)} credit remaining` };
+ }
+ }
+ } catch {
+ // fall through to the plain auth result
+ }
+
return { ok: res.ok, detail: `HTTP ${res.status}` };
},
async whatsapp(env) {
@@ -146,6 +197,14 @@ const defaultProbes = {
await client.connect();
const pong = await client.ping();
return { ok: pong === 'PONG', detail: pong };
+ } catch (err) {
+ // ioredis reports an unreachable server as "Connection is closed.", which
+ // says nothing about where it tried or why. Name the address instead.
+ const host = String(env.REDIS_URL || '').replace(/\/\/[^@/]*@/, '//');
+ if (/Connection is closed/i.test(err.message)) {
+ throw new Error(`nothing answered at ${host}`);
+ }
+ throw new Error(`${err.message} (${host})`);
} finally {
client.disconnect();
}
@@ -172,7 +231,9 @@ async function runDoctor({
env = process.env,
probes = defaultProbes,
setupState, // inject a parsed .setup-state.json (or null) in tests; otherwise read from disk
- statePath = path.resolve(process.cwd(), '.setup-state.json'),
+ // Repo-anchored, like every other path here: run from bot/ it would otherwise
+ // report "no flows registered" for a deployment that has them all.
+ statePath = path.resolve(__dirname, '../../..', '.setup-state.json'),
} = {}) {
const analysis = analyzeEnv(env);
@@ -224,7 +285,15 @@ async function runDoctor({
const probesPassed = probeResults.every((p) => p.status !== 'fail');
const ok = analysis.missingRequired.length === 0 && probesPassed;
- return { ok, missingRequired: analysis.missingRequired, probeResults, featureResults, flowResults };
+ return {
+ ok,
+ missingRequired: analysis.missingRequired,
+ probeResults,
+ featureResults,
+ flowResults,
+ channel: analysis.channel,
+ channelDriverTypo: analysis.channelDriverTypo,
+ };
}
// ── Pretty printer ────────────────────────────────────────────────────────────
@@ -233,6 +302,26 @@ function formatReport(result) {
const mark = (s) => ({ pass: '✅', fail: '❌', skip: '⏭️ ', on: '✅', off: '➖' }[s] || '•');
const lines = [];
lines.push('Rumi doctor — deployment preflight');
+ if (result.channel) {
+ const tier = isProductionTier(result.channel) ? 'production' : 'sandbox';
+ lines.push(`Channel driver: ${result.channel} (${tier})`);
+ if (result.channel === 'baileys') {
+ lines.push(
+ 'ℹ️ Baileys sends/receives text, image, audio, and document messages once paired — run'
+ + ' `rumi pair` if you have not yet. WhatsApp Flows, approved templates, and'
+ + ' carousels have no Baileys equivalent (Meta-only) and log clearly rather than sending.'
+ + ' A green result below means your OTHER required services are configured — it does NOT'
+ + ' confirm messaging works end to end; pair and send yourself a test message to confirm that.'
+ + ' See docs/onboarding/sandbox-production-design.md.'
+ );
+ }
+ }
+ if (result.channelDriverTypo) {
+ lines.push(
+ `⚠️ CHANNEL_DRIVER="${result.channelDriverTypo}" is not a recognized driver (valid: meta | baileys) —`
+ + ` falling back to ${result.channel}.`
+ );
+ }
lines.push('');
if (result.missingRequired.length) {
lines.push('❌ MISSING REQUIRED variables — the bot will REFUSE TO START until you set these:');
@@ -281,7 +370,9 @@ function formatReport(result) {
// ── CLI entry ──────────────────────────────────────────────────────────────────
async function main() {
- try { require('dotenv').config(); } catch { /* dotenv optional */ }
+ try {
+ require('dotenv').config({ path: path.resolve(__dirname, '../../..', '.env'), quiet: true });
+ } catch { /* dotenv optional */ }
const result = await runDoctor({});
console.log(formatReport(result));
process.exit(result.ok ? 0 : 1);
@@ -289,4 +380,19 @@ async function main() {
if (require.main === module) main();
-module.exports = { analyzeEnv, analyzeFlows, runDoctor, formatReport, keySource, KEY_SOURCES, REQUIRED_VARS, FEATURES };
+module.exports = {
+ analyzeEnv,
+ analyzeFlows,
+ runDoctor,
+ formatReport,
+ keySource,
+ KEY_SOURCES,
+ REQUIRED_VARS,
+ CHANNEL_REQUIRED_VARS,
+ requiredVarsFor,
+ resolveChannelDriver,
+ FEATURES,
+ // Exported so the real probes' behaviour can be tested (the runner injects
+ // fakes, which meant nothing verified the probes themselves).
+ defaultProbes,
+};
diff --git a/bot/scripts/setup/env-file.js b/bot/scripts/setup/env-file.js
new file mode 100644
index 0000000..5d1be64
--- /dev/null
+++ b/bot/scripts/setup/env-file.js
@@ -0,0 +1,109 @@
+/**
+ * .env file patcher — read/update specific keys in place, preserving every
+ * other line (comments, ordering, unrelated vars) verbatim. Both `rumi setup`
+ * (interactive-setup.js) and `rumi graduate` (graduate.js) need this: neither
+ * should ever regenerate a user's .env from the template, only patch the
+ * keys it's actually setting.
+ *
+ * @module env-file
+ */
+
+const fs = require('fs');
+
+// Read a file as LF-normalized lines regardless of its original line endings
+// (CRLF or LF) — writeEnvVars always writes LF, so mixing would otherwise
+// leave a stray \r on every untouched line of a CRLF-authored .env.
+function readLines(filePath) {
+ return fs.readFileSync(filePath, 'utf-8').split(/\r\n|\n/);
+}
+
+// Returns the KEY for a KEY=VALUE line, or null for a comment/blank/malformed line.
+function keyOf(line) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) return null;
+ const eq = trimmed.indexOf('=');
+ if (eq === -1) return null;
+ return trimmed.slice(0, eq).trim();
+}
+
+/**
+ * @param {string} envPath
+ * @returns {Record} parsed KEY=VALUE pairs (best-effort; lines
+ * that aren't KEY=VALUE, comments, and blank lines are ignored). A key that
+ * appears more than once resolves to its LAST occurrence, matching dotenv's
+ * own parsing behavior.
+ */
+function readEnvFile(envPath) {
+ if (!fs.existsSync(envPath)) return {};
+ const result = {};
+ for (const line of readLines(envPath)) {
+ const key = keyOf(line);
+ if (key === null) continue;
+ const trimmed = line.trim();
+ result[key] = trimmed.slice(trimmed.indexOf('=') + 1).trim();
+ }
+ return result;
+}
+
+/**
+ * Patches the given KEY=VALUE pairs into envPath in place: an existing line
+ * for a key is replaced (preserving its position); a key with no existing
+ * line is appended at the end. Every other line is left alone (byte-for-byte,
+ * modulo EOL normalization to LF — see readLines). Creates the file (from
+ * `fromTemplatePath`, if given and envPath doesn't exist yet) rather than
+ * ever regenerating an existing one.
+ *
+ * If a key being patched appears MORE THAN ONCE in the file (a hand-edited
+ * duplicate, a merge artifact, ...), every occurrence but the last is
+ * dropped and the last is replaced — dotenv resolves duplicates
+ * last-occurrence-wins, so patching only the first would silently leave the
+ * value actually loaded at runtime untouched.
+ *
+ * @param {string} envPath
+ * @param {Record} updates
+ * @param {{ fromTemplatePath?: string }} [opts]
+ */
+function writeEnvVars(envPath, updates, opts = {}) {
+ if (!fs.existsSync(envPath)) {
+ if (opts.fromTemplatePath && fs.existsSync(opts.fromTemplatePath)) {
+ fs.copyFileSync(opts.fromTemplatePath, envPath);
+ } else {
+ fs.writeFileSync(envPath, '');
+ }
+ }
+
+ const lines = readLines(envPath);
+ const remaining = new Set(Object.keys(updates));
+
+ const lastIndexForKey = new Map();
+ lines.forEach((line, i) => {
+ const key = keyOf(line);
+ if (key !== null && remaining.has(key)) lastIndexForKey.set(key, i);
+ });
+
+ const patched = [];
+ lines.forEach((line, i) => {
+ const key = keyOf(line);
+ if (key === null || !remaining.has(key)) {
+ patched.push(line);
+ return;
+ }
+ if (i !== lastIndexForKey.get(key)) return; // drop an earlier duplicate of this key
+ patched.push(`${key}=${updates[key]}`);
+ remaining.delete(key);
+ });
+
+ for (const key of remaining) {
+ patched.push(`${key}=${updates[key]}`);
+ }
+
+ // Drop a single trailing blank line this loop may have introduced, then
+ // ensure the file still ends with exactly one newline.
+ while (patched.length > 1 && patched[patched.length - 1] === '' && patched[patched.length - 2] === '') {
+ patched.pop();
+ }
+
+ fs.writeFileSync(envPath, `${patched.join('\n').replace(/\n+$/, '')}\n`);
+}
+
+module.exports = { readEnvFile, writeEnvVars };
diff --git a/bot/scripts/setup/fields.js b/bot/scripts/setup/fields.js
new file mode 100644
index 0000000..df9b598
--- /dev/null
+++ b/bot/scripts/setup/fields.js
@@ -0,0 +1,136 @@
+/**
+ * fields.js — the human-facing description of every value the `rumi` CLI can
+ * collect: what to call it, why anyone would want it, and where to find it.
+ *
+ * It sits apart from the wizard for one reason: `rumi setup` and
+ * `rumi graduate` both collect Meta's credentials, and when the wording lived
+ * in each of them, one got the careful "this is the phone number ID, not the
+ * phone number" guidance and the other asked for `PHONE_NUMBER_ID` and left the
+ * user to guess. Sharing the copy makes that impossible.
+ *
+ * Env-var names appear here only as the storage key. Nothing in this file is
+ * *asked* by its variable name — a person setting up Rumi should never have to
+ * know that "Access token" is `WHATSAPP_TOKEN` to answer the question.
+ *
+ * @module fields
+ */
+
+const validators = require('./validators');
+
+/**
+ * @typedef {object} Field
+ * @property {string} env the .env key this is stored as
+ * @property {string} label what the human is asked for
+ * @property {string} [hint] one line of context, printed above the prompt
+ * @property {boolean} [secret] read masked
+ * @property {Function} [validate]
+ * @property {Function} [generate] produces a sensible value to offer as the default
+ */
+
+// ── Meta / WhatsApp Business (the production channel) ────────────────────────
+
+/** Where to look, before being asked for anything. */
+const META_WALKTHROUGH = [
+ 'Open https://developers.facebook.com/apps and open your app (or create one — pick the "Business" type)',
+ 'In the left sidebar choose WhatsApp → API Setup',
+ 'Keep that tab open: the next three answers are all on it',
+];
+
+/** @type {Field[]} */
+const META_FIELDS = [
+ {
+ env: 'WHATSAPP_TOKEN',
+ label: 'Access token',
+ secret: true,
+ hint: 'On API Setup, the box at the top — click "Generate access token". The temporary one expires in 24 hours, which is fine for a first test; for a real deployment create a permanent System User token instead.',
+ validate: validators.whatsappToken,
+ },
+ {
+ env: 'PHONE_NUMBER_ID',
+ label: 'Phone number ID',
+ hint: 'Directly under the "From" dropdown, labelled "Phone number ID". This is a long number Meta assigns — not the phone number itself.',
+ validate: validators.phoneNumberId,
+ },
+ {
+ env: 'WABA_ID',
+ label: 'WhatsApp Business Account ID',
+ hint: 'Just below the phone number ID on the same page.',
+ validate: validators.wabaId,
+ },
+ {
+ env: 'WEBHOOK_VERIFY_TOKEN',
+ label: 'Webhook password',
+ hint: 'You invent this one. Meta will ask you to type the same value when you point it at your server, and sends it back so Rumi can recognise a genuine request. Press Enter to use the one generated for you.',
+ validate: validators.webhookVerifyToken,
+ generate: () => require('crypto').randomBytes(16).toString('hex'),
+ },
+];
+
+/** What still has to happen in Meta's console once the four values are in. */
+const META_REMAINING_STEPS = [
+ 'Deploy Rumi somewhere with a public HTTPS address (SETUP.md covers Railway)',
+ 'In Meta\'s console: WhatsApp → Configuration → Webhook, set the callback URL to https://your-address/webhook and the verify token to the webhook password you just chose',
+ 'Subscribe the webhook to the "messages" field — without it Meta accepts the URL but never sends anything',
+ 'Register the interactive Flows: npm run setup:flows',
+];
+
+/**
+ * @param {string} driver
+ * @returns {Field[]} the fields that driver needs — empty for any sandbox
+ * driver, which is the point of sandbox: nothing to register, nothing to ask.
+ */
+function fieldsFor(driver) {
+ return driver === 'meta' ? META_FIELDS : [];
+}
+
+// ── Optional abilities ───────────────────────────────────────────────────────
+
+/**
+ * Presence-gated extras, described by what a teacher would notice if it were
+ * missing rather than by the vendor's product name. Order is deliberate: the
+ * ones that change day-to-day use come first, so someone who stops reading
+ * halfway has still seen the ones that matter.
+ *
+ * @type {Array<{keys: string[], title: string, why: string, where: string, secret?: boolean}>}
+ */
+const OPTIONAL_EXTRAS = [
+ {
+ keys: ['SONIOX_API_KEY'],
+ title: 'Understand voice notes',
+ why: 'Teachers talk more than they type. With this, Rumi transcribes voice notes in English, Urdu, Arabic and Spanish — and it is what reading assessments run on.',
+ where: 'console.soniox.com',
+ secret: true,
+ },
+ {
+ keys: ['ELEVENLABS_API_KEY'],
+ title: 'Reply out loud',
+ why: 'Rumi answers with a spoken message instead of text — useful for teachers who find reading on a phone slow.',
+ where: 'elevenlabs.io → API Keys',
+ secret: true,
+ },
+ {
+ keys: ['GAMMA_API_KEY'],
+ title: 'Turn lesson plans into slides',
+ why: 'Rumi builds a presentation a teacher can actually project in class. Needs a paid Gamma plan.',
+ where: 'gamma.app/settings/api-keys',
+ secret: true,
+ },
+ {
+ keys: ['AZURE_SPEECH_KEY', 'AZURE_SPEECH_REGION'],
+ title: 'Score pronunciation',
+ why: 'Adds per-word pronunciation marks to reading assessments, on top of speed and accuracy.',
+ where: 'portal.azure.com → create a Speech resource',
+ secret: true,
+ },
+ {
+ keys: ['MISTRAL_API_KEY'],
+ title: 'Read handwriting on exam papers',
+ why: 'Lets Rumi mark a photographed exam paper, not just a printed worksheet.',
+ where: 'console.mistral.ai → API Keys',
+ secret: true,
+ },
+];
+
+module.exports = {
+ META_FIELDS, META_WALKTHROUGH, META_REMAINING_STEPS, fieldsFor, OPTIONAL_EXTRAS,
+};
diff --git a/bot/scripts/setup/graduate.js b/bot/scripts/setup/graduate.js
new file mode 100755
index 0000000..b8a89a2
--- /dev/null
+++ b/bot/scripts/setup/graduate.js
@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+/**
+ * graduate.js — `rumi graduate`, the move from a sandbox channel to a real one.
+ *
+ * The only real target today is `meta` (the sole production-tier driver), but
+ * the target is still a `--to=` argument resolved through
+ * channel-registry.js rather than hardcoded, so a future driver follows the
+ * same shape without this file changing.
+ *
+ * Two design commitments, both about not breaking a working deployment:
+ *
+ * - **Validate before touching anything.** The new credentials are checked
+ * against the live service first. A failed graduation leaves `.env` exactly
+ * as it was, so a wrong paste costs a retry rather than an outage on a
+ * channel that had been working.
+ * - **Retire, don't delete.** The outgoing session folder is renamed, not
+ * removed, so going back is possible if the new channel disappoints.
+ *
+ * No data migration is involved by design: users are keyed by phone number, not
+ * by channel, so conversation history, registrations and coaching sessions
+ * carry across on their own. The one thing that cannot carry across is the
+ * number itself — which is why the closing checklist says so out loud.
+ *
+ * Usage:
+ * rumi graduate [--to=meta]
+ *
+ * @module graduate
+ */
+
+// A `rumi` command is a conversation with a person, so its output must stay
+// human-readable. bot/shared/utils/structured-logger.js replaces console.* with
+// JSON logging for the server, and any module that reaches it (the WhatsApp
+// connection does) would take this command's output with it — a QR code and a
+// wizard, rendered as log records. Set before the first require, since the
+// override happens at import time.
+process.env.RUMI_CLI = '1';
+
+const fs = require('fs');
+const path = require('path');
+
+const ui = require('./ui');
+const { createIo, PromptAbortError } = require('./prompt');
+const { readEnvFile, writeEnvVars } = require('./env-file');
+const fields = require('./fields');
+const { DRIVERS, isKnownDriver, isProductionTier } = require('../../shared/services/messaging/channel-registry');
+const { resolveChannelDriver, CHANNEL_REQUIRED_VARS } = require('../../shared/config/feature-availability');
+
+const ROOT = path.resolve(__dirname, '../../..');
+const ENV_PATH = path.join(ROOT, '.env');
+
+function parseArgs(argv) {
+ const args = {};
+ for (const arg of argv.slice(2)) {
+ const match = arg.match(/^--([^=]+)=(.*)$/);
+ if (match) args[match[1]] = match[2];
+ }
+ return args;
+}
+
+/**
+ * Asks for the target driver's credentials, using the same human labels,
+ * guidance and shape checks as `rumi setup` — sharing fields.js is what keeps
+ * the two commands from drifting into two different qualities of explanation.
+ *
+ * @param {object} io
+ * @param {string} targetDriver
+ * @param {string} [envPath]
+ * @returns {Promise>}
+ */
+async function promptForTargetVars(io, targetDriver, envPath = ENV_PATH) {
+ const definitions = fields.fieldsFor(targetDriver);
+ // Any driver we have no copy for yet still gets asked — by env var name, which
+ // is ugly but honest, and better than silently collecting nothing.
+ const specs = definitions.length
+ ? definitions
+ : (CHANNEL_REQUIRED_VARS[targetDriver] || []).map((env) => ({ env, label: env }));
+
+ const existingEnv = readEnvFile(envPath);
+ const collected = {};
+ for (const spec of specs) {
+ const existing = existingEnv[spec.env];
+ const prefill = existing && !/^CHANGEME/i.test(existing) ? existing : '';
+ console.log('');
+ // eslint-disable-next-line no-await-in-loop -- one question at a time, by design
+ collected[spec.env] = await io.ask(spec.label, {
+ hint: spec.hint,
+ secret: Boolean(spec.secret),
+ fallback: prefill || (spec.generate ? spec.generate() : ''),
+ validate: spec.validate,
+ });
+ }
+ return collected;
+}
+
+/**
+ * Checks the target's credentials against the live service, using doctor's own
+ * probe so "valid" means the same thing here as it does in `rumi doctor`.
+ *
+ * @returns {Promise<{ok: boolean, detail: string}>}
+ */
+async function validateTargetCredentials(targetDriver, mergedEnv) {
+ if (targetDriver !== 'meta') {
+ return { ok: true, detail: 'no live check exists for this channel yet — trusting the values as given' };
+ }
+ const { runDoctor } = require('./doctor');
+ const result = await runDoctor({ env: mergedEnv });
+ const probe = result.probeResults.find((p) => p.name.includes('WhatsApp'));
+ if (!probe) return { ok: false, detail: 'no WhatsApp probe result' };
+ return { ok: probe.status === 'pass', detail: probe.detail };
+}
+
+/**
+ * Renames CHANNEL_STATE_DIR/ to .retired. Because every driver
+ * keeps its state under one shared root, this is the same line of code whichever
+ * channel is being left behind.
+ *
+ * @returns {{from: string, to: string}|null} null when there was nothing to retire
+ */
+function retireOutgoingDriverState(outgoingDriver, env) {
+ const stateDir = env.CHANNEL_STATE_DIR || '.channel-state';
+ // Repo-anchored to match baileys-connection.js's authDir(): retiring a
+ // different folder from the one the driver reads would leave the live session
+ // in place and "retire" nothing.
+ const outgoingPath = path.isAbsolute(stateDir)
+ ? path.join(stateDir, outgoingDriver)
+ : path.resolve(ROOT, stateDir, outgoingDriver);
+ if (!fs.existsSync(outgoingPath)) return null;
+ const retiredPath = `${outgoingPath}.retired`;
+ fs.renameSync(outgoingPath, retiredPath);
+ return { from: outgoingPath, to: retiredPath };
+}
+
+/** What genuinely cannot be done from inside this repo. */
+function printManualChecklist(targetDriver) {
+ console.log('');
+ console.log(ui.rule());
+ console.log(` ${ui.paint('brand', `Rumi now runs on ${targetDriver}.`, { bold: true })}`);
+ console.log('');
+ if (targetDriver === 'meta') {
+ console.log(ui.bold(' Still to do, in Meta\'s console'));
+ console.log(ui.steps(fields.META_REMAINING_STEPS));
+ console.log('');
+ console.log(ui.bold(' And tell your testers'));
+ console.log(ui.say('Your official number is a different number from the one you were testing on. Anyone who was messaging the old one has to start a new chat with the new one — there is no way to forward messages between them.'));
+ }
+ console.log('');
+}
+
+async function main() {
+ try { require('dotenv').config({ path: ENV_PATH, quiet: true }); } catch { /* dotenv optional */ }
+
+ const args = parseArgs(process.argv);
+ const target = (args.to || 'meta').trim().toLowerCase();
+
+ if (!isKnownDriver(target)) {
+ console.log(ui.fail(`"${target}" is not a channel Rumi knows. Try: ${Object.keys(DRIVERS).join(', ')}.`));
+ process.exitCode = 1;
+ return;
+ }
+
+ const current = resolveChannelDriver(process.env);
+ if (current === target) {
+ console.log(ui.ok(`Already running on ${target} — nothing to do.`));
+ return;
+ }
+
+ console.log(ui.logo(`Moving from ${current} to ${target}`));
+ if (isProductionTier(target)) {
+ console.log(ui.say('Teachers, conversations and past assessments all carry over on their own — Rumi identifies people by phone number, not by channel, so there is no data migration here.'));
+ console.log('');
+ console.log(ui.say('Nothing is changed until the new credentials have been checked against Meta. If they do not work, your current setup is left exactly as it is.'));
+ }
+
+ const io = createIo();
+ let collected;
+ try {
+ collected = await promptForTargetVars(io, target);
+ } catch (err) {
+ if (!(err instanceof PromptAbortError || err.aborted)) throw err;
+ console.log('');
+ console.log(ui.say('Stopped. Nothing was changed — Rumi is still on your current channel.'));
+ process.exitCode = 130;
+ return;
+ }
+ const mergedEnv = { ...process.env, ...collected };
+
+ console.log('');
+ const spin = ui.spinner('Checking the new credentials before changing anything…');
+ const probe = await validateTargetCredentials(target, mergedEnv);
+ if (!probe.ok) {
+ spin.fail(`Those credentials were rejected (${probe.detail})`);
+ console.log(ui.aside('Nothing was changed — Rumi is still on your current channel. The usual cause is an access token that has expired; Meta\'s temporary ones last 24 hours. Fix the value and run `rumi graduate` again.'));
+ process.exitCode = 1;
+ return;
+ }
+ spin.succeed(`Credentials accepted ${ui.dim(probe.detail)}`);
+
+ writeEnvVars(ENV_PATH, { ...collected, CHANNEL_DRIVER: target }, {
+ fromTemplatePath: path.resolve(ROOT, '.env.template'),
+ });
+ console.log(ui.ok('Saved.'));
+
+ const retired = retireOutgoingDriverState(current, mergedEnv);
+ if (retired) {
+ console.log(ui.ok(`Set aside the old ${current} session ${ui.dim(`(${path.basename(retired.to)}, kept in case you want to go back)`)}`));
+ }
+
+ printManualChecklist(target);
+}
+
+if (require.main === module) {
+ main()
+ .then(() => process.exit(process.exitCode || 0))
+ .catch((err) => {
+ console.log(ui.fail(`Could not finish: ${err.message}`));
+ process.exit(1);
+ });
+}
+
+module.exports = {
+ main, parseArgs, promptForTargetVars, validateTargetCredentials, retireOutgoingDriverState,
+ printManualChecklist,
+};
diff --git a/bot/scripts/setup/interactive-setup.js b/bot/scripts/setup/interactive-setup.js
new file mode 100755
index 0000000..fc5e0d0
--- /dev/null
+++ b/bot/scripts/setup/interactive-setup.js
@@ -0,0 +1,755 @@
+#!/usr/bin/env node
+/**
+ * interactive-setup.js — `rumi setup`, the guided path from a fresh clone to a
+ * WhatsApp conversation with Rumi.
+ *
+ * Written for the person who has never seen this codebase: someone at a school
+ * or an NGO who was told "you can run this yourself". Four things follow from
+ * that, and they explain most of the shape of this file:
+ *
+ * 1. **Nothing is asked by its variable name.** The question is "where does
+ * Rumi keep its memory", not "SUPABASE_URL". Storage keys are an
+ * implementation detail of `.env`, not vocabulary the user must learn.
+ * 2. **Every answer is checked while the person who typed it is still here.**
+ * A key that is merely *present* tells you nothing; the failure it causes
+ * surfaces hours later inside a feature, with no clue which of eight
+ * values was wrong. Each step probes the real service before moving on.
+ * 3. **Progress is saved after each step, not at the end.** Ctrl+C is a
+ * legitimate way to leave — the browser tab for the next credential is
+ * often the reason — so quitting must never cost work already done.
+ * 4. **Anything already working is not asked about again.** Re-running the
+ * wizard on a configured deployment should take seconds and change
+ * nothing. Pass `--reconfigure` to be asked everything regardless.
+ *
+ * Usage:
+ * rumi setup [--reconfigure]
+ * node bot/scripts/setup/interactive-setup.js
+ *
+ * @module interactive-setup
+ */
+
+// A `rumi` command is a conversation with a person, so its output must stay
+// human-readable. bot/shared/utils/structured-logger.js replaces console.* with
+// JSON logging for the server, and any module that reaches it (the WhatsApp
+// connection does) would take this command's output with it — a QR code and a
+// wizard, rendered as log records. Set before the first require, since the
+// override happens at import time.
+process.env.RUMI_CLI = '1';
+
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const ui = require('./ui');
+const { createIo, PromptAbortError } = require('./prompt');
+const { readEnvFile, writeEnvVars } = require('./env-file');
+const validators = require('./validators');
+const dbSetup = require('./db-setup');
+const fields = require('./fields');
+const summary = require('./summary');
+
+const ROOT = path.resolve(__dirname, '../../..');
+const ENV_PATH = path.join(ROOT, '.env');
+const ENV_TEMPLATE_PATH = path.join(ROOT, '.env.template');
+const TOTAL_STEPS = 5;
+const LOCAL_REDIS = { url: 'redis://localhost:6379', container: 'rumi-redis', image: 'redis:7-alpine' };
+
+// ── Shared plumbing ──────────────────────────────────────────────────────────
+
+/**
+ * Builds the `save` function every step uses: patches `.env` in place and
+ * updates the live env, so the next step's live check sees what the last step
+ * collected without a restart.
+ *
+ * @param {object} env mutated in place — this is the wizard's working env
+ * @returns {(vars: Record) => void}
+ */
+function createSaver(env, envPath = ENV_PATH) {
+ return (vars) => {
+ const meaningful = Object.fromEntries(
+ Object.entries(vars).filter(([, value]) => value !== undefined && value !== ''),
+ );
+ if (!Object.keys(meaningful).length) return;
+ Object.assign(env, meaningful);
+ writeEnvVars(envPath, meaningful, { fromTemplatePath: ENV_TEMPLATE_PATH });
+ };
+}
+
+/**
+ * Runs one of doctor.js's live probes. Probes throw on network failure; a
+ * wizard step wants a verdict, never an exception, so failures come back as
+ * `{ok: false}` with the message the user should see.
+ *
+ * @returns {Promise<{ok: boolean, detail: string}>}
+ */
+async function probe(name, env) {
+ const { defaultProbes } = require('./doctor');
+ try {
+ return await defaultProbes[name](env);
+ } catch (err) {
+ return { ok: false, detail: err.message };
+ }
+}
+
+/**
+ * The values `.env.template` ships, so the wizard can tell a real answer from a
+ * value it put there itself. Read once, lazily.
+ */
+let templateDefaultsCache;
+function templateDefaults(templatePath = ENV_TEMPLATE_PATH) {
+ if (!templateDefaultsCache) {
+ try { templateDefaultsCache = readEnvFile(templatePath); } catch { templateDefaultsCache = {}; }
+ }
+ return templateDefaultsCache;
+}
+
+/**
+ * Did a *person* give us this value?
+ *
+ * Not the same question as "is it non-empty". `.env` is created from the
+ * template, and the template ships working-looking values —
+ * `REDIS_URL=redis://localhost:6379`, `SUPABASE_URL=https://your-project.supabase.co`
+ * — which are suggestions, not configuration. Counting them as answers produced
+ * three wrong things on a genuinely fresh install, all seen in a live run:
+ * "Picking up from last time — 2 of 3 core services are already configured" on a
+ * clone that had configured nothing; "Checking the Redis you already have… ✘"
+ * about a Redis nobody had claimed to have; and prompts offering
+ * `[https://your-project.supabase.co]` as the value to keep, where pressing
+ * Enter accepts a placeholder.
+ *
+ * @returns {boolean}
+ */
+function isProvided(env, key) {
+ const value = env[key];
+ if (!value) return false;
+ if (/^CHANGEME/i.test(value)) return false;
+ if (/^(your-|https:\/\/your-)/i.test(value)) return false;
+ return value !== templateDefaults()[key];
+}
+
+/** True when a person has given us every one of `keys`. */
+function hasAll(env, keys) {
+ return keys.every((key) => isProvided(env, key));
+}
+
+/** The value to offer as "press Enter to keep this", or '' when there is none. */
+function prefill(env, key) {
+ return isProvided(env, key) ? env[key] : '';
+}
+
+/**
+ * A value that is still the template's own suggestion — worth trying quietly,
+ * but not worth announcing as something the user already had.
+ */
+function isTemplateSuggestion(env, key) {
+ return Boolean(env[key]) && !isProvided(env, key) && !/^CHANGEME/i.test(env[key]);
+}
+
+/**
+ * Opens a step at the top of a cleared screen, with a tick for everything
+ * already done above it.
+ *
+ * The clearing is why: a prompt printed after five screens of scroll sits on the
+ * terminal's bottom line, far from the explanation it belongs to. The ticks are
+ * why it is safe to clear — orientation survives, in one line per step instead
+ * of a screenful.
+ */
+const completedSteps = [];
+function beginStep(index, title) {
+ ui.clearScreen();
+ console.log('');
+ console.log(ui.progressBar(index, TOTAL_STEPS));
+ // What is already done reads as history above the current step, not as a note
+ // underneath its heading.
+ for (const done of completedSteps) console.log(` ${ui.ok(ui.dim(done))}`);
+ console.log('');
+ console.log(ui.bold(title));
+}
+
+/** Records a step as done, so later screens can show it as a tick. */
+function finishStep(title) {
+ if (!completedSteps.includes(title)) completedSteps.push(title);
+}
+
+/**
+ * A live check with a spinner, retried by the caller on failure. Returns the
+ * probe verdict so a step can decide whether "no" is fatal.
+ */
+async function checkLive(label, name, env, successText) {
+ const spin = ui.spinner(label);
+ const result = await probe(name, env);
+ if (result.ok) spin.succeed(successText(result.detail));
+ else spin.fail(ui.paint('danger', result.detail));
+ return result;
+}
+
+// ── Step 1: the database ─────────────────────────────────────────────────────
+
+const SUPABASE_WALKTHROUGH = [
+ 'Open https://supabase.com/dashboard/new and sign in (the free plan is plenty)',
+ 'Give the project any name, choose the region closest to your teachers, and let it start up — about two minutes',
+ 'Open Project Settings → Data API, and keep that page open',
+];
+
+/**
+ * Collects and verifies the Supabase connection, then makes sure Rumi's tables
+ * exist. Writes through the caller's saver as it goes rather than returning
+ * anything — every step is durable the moment its answer is accepted.
+ */
+async function stepDatabase(io, env, save, opts = {}) {
+ beginStep(1, 'Where Rumi keeps its memory');
+ console.log(ui.say('Every teacher, lesson plan and reading score Rumi produces is stored in a database that belongs to you — not to us. Supabase gives you one free.'));
+
+ const configured = hasAll(env, ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY']);
+ if (configured && !opts.reconfigure) {
+ const check = await checkLive('Checking the database you already have…', 'supabase', env, () => 'Database already connected');
+ if (check.ok) return ensureTables(io, env);
+ console.log(ui.say('Let us set that up again.'));
+ }
+
+ console.log('');
+ console.log(ui.steps(SUPABASE_WALKTHROUGH));
+
+ for (;;) {
+ const url = await io.ask('Project URL', {
+ fallback: prefill(env, 'SUPABASE_URL'),
+ validate: validators.supabaseUrl,
+ hint: 'On that page, the field called "Project URL".',
+ });
+ const key = await io.ask('Service key', {
+ secret: true,
+ fallback: prefill(env, 'SUPABASE_SERVICE_ROLE_KEY'),
+ validate: validators.supabaseServiceKey,
+ hint: 'The "service_role" key, further down the same page — you have to click Reveal to see it. It is hidden as you type.',
+ });
+
+ save({ SUPABASE_URL: url, SUPABASE_SERVICE_ROLE_KEY: key });
+ const check = await checkLive('Talking to your database…', 'supabase', env, (d) => `Connected to Supabase ${ui.dim(d)}`);
+ if (check.ok) break;
+ console.log(ui.aside('That did not connect. Check the project has finished starting up, then try again.'));
+ }
+
+ return ensureTables(io, env);
+}
+
+/**
+ * Creates Rumi's tables if they aren't there.
+ *
+ * The `exec_sql` detour is unavoidable and is the single most common place a
+ * self-serve setup dies: Supabase offers no API for running arbitrary SQL, so
+ * the schema is applied through a small function that itself has to be pasted
+ * in by hand, once. Naming that plainly — and linking straight to the right
+ * project's SQL editor — is the difference between a two-minute step and an
+ * abandoned install.
+ */
+async function ensureTables(io, env) {
+ const spin = ui.spinner('Looking for Rumi\'s tables…');
+ const status = await dbSetup.inspectDatabase(env);
+ spin.stop();
+
+ if (status.state === 'ready') {
+ console.log(ui.ok(`Tables already set up ${ui.dim(`(${status.detail})`)}`));
+ return;
+ }
+ if (status.state === 'unreachable') {
+ console.log(ui.warn(`Could not check the tables: ${status.detail}`));
+ return;
+ }
+
+ if (status.state === 'needs-helper') {
+ console.log('');
+ console.log(ui.say('Rumi needs to create about seventy tables. Supabase does not allow a script to run SQL directly, so there is one thing to paste by hand first — this is the only manual step in the whole setup.'));
+ console.log('');
+ const editor = dbSetup.sqlEditorUrl(env.SUPABASE_URL);
+ console.log(ui.steps([
+ editor ? `Open ${editor}` : 'Open the SQL Editor in your Supabase project',
+ 'Paste the two lines below and press Run',
+ 'Come back here',
+ ]));
+ console.log('');
+ console.log(ui.box(dbSetup.EXEC_SQL_DEFINITION, { title: 'copy this', role: 'accent' }));
+ console.log('');
+ await io.pressEnter('Press Enter once you have run it');
+
+ const recheck = await dbSetup.hasExecSql(env);
+ if (!recheck.present) {
+ console.log(ui.warn('Still cannot see it. Skipping the tables for now — run `npm run bootstrap:db` once the two lines are in place.'));
+ return;
+ }
+ }
+
+ const applying = ui.spinner('Creating tables, security rules and starter data… (about a minute)');
+ const result = await dbSetup.applySchema(env);
+ if (result.ok) applying.succeed(`Database ready ${ui.dim(`(${result.applied.length} files applied)`)}`);
+ else {
+ applying.fail('Could not finish setting up the tables');
+ for (const failure of result.errors) console.log(ui.aside(`${failure.file}: ${failure.error}`));
+ console.log(ui.aside('Setup will carry on — retry the tables later with `npm run bootstrap:db`.'));
+ }
+}
+
+// ── Step 2: the AI ───────────────────────────────────────────────────────────
+
+async function stepBrain(io, env, save, opts = {}) {
+ beginStep(2, 'How Rumi thinks');
+ console.log(ui.say('Rumi reaches AI models through OpenRouter — one account, many models, so you are never locked to a single provider. A typical reply costs a fraction of a cent.'));
+
+ if (hasAll(env, ['OPENROUTER_API_KEY']) && !opts.reconfigure) {
+ const check = await checkLive('Checking the AI key you already have…', 'openrouter', env, (d) => `AI already connected ${ui.dim(d)}`);
+ if (check.ok) return;
+ console.log(ui.say('Let us set that up again.'));
+ }
+
+ console.log('');
+ console.log(ui.steps([
+ 'Open https://openrouter.ai/keys and create a key',
+ 'Add a few dollars of credit at https://openrouter.ai/settings/credits',
+ ]));
+ console.log(ui.aside('The credit matters: a key with none will answer a greeting and then fail on anything substantial, like generating a quiz. That failure looks like a bug in Rumi, so it is worth doing now.'));
+
+ for (;;) {
+ const key = await io.ask('API key', {
+ secret: true,
+ fallback: prefill(env, 'OPENROUTER_API_KEY'),
+ validate: validators.openrouterKey,
+ });
+ save({ OPENROUTER_API_KEY: key });
+
+ const spin = ui.spinner('Checking the key and its balance…');
+ const check = await probe('openrouter', env);
+ if (check.ok) {
+ spin.succeed(`AI connected ${ui.dim(check.detail)}`);
+ return;
+ }
+
+ // A rejected key is a different problem from a valid key with no money on
+ // it, and only the first is worth re-asking about.
+ const noCredit = /credit/i.test(check.detail);
+ if (noCredit) {
+ spin.warn(check.detail);
+ const carryOn = await io.confirm('That key works but cannot pay for a request yet. Carry on and add credit later?', true);
+ if (carryOn) return;
+ } else {
+ spin.fail(ui.paint('danger', `That key was rejected (${check.detail})`));
+ }
+ }
+}
+
+// ── Step 3: Redis ────────────────────────────────────────────────────────────
+
+/** Is there a Docker daemon we could actually start a container on? */
+function dockerAvailable() {
+ const result = spawnSync('docker', ['info'], { stdio: 'ignore', timeout: 10_000 });
+ return result.status === 0;
+}
+
+/**
+ * Starts (or restarts) a local Redis container. Reusing a container that
+ * already exists matters more than it sounds: someone re-running setup would
+ * otherwise hit "the container name is already in use" and be stuck at a
+ * Docker error in the middle of a WhatsApp tutorial.
+ *
+ * @returns {{ok: boolean, detail: string}}
+ */
+function startLocalRedis(run = spawnSync) {
+ const created = run('docker', ['run', '-d', '--name', LOCAL_REDIS.container, '-p', '6379:6379', LOCAL_REDIS.image], { encoding: 'utf-8' });
+ if (created.status === 0) return { ok: true, detail: 'started a new container' };
+
+ const message = `${created.stderr || ''}`;
+ if (/already in use/i.test(message)) {
+ const started = run('docker', ['start', LOCAL_REDIS.container], { encoding: 'utf-8' });
+ if (started.status === 0) return { ok: true, detail: 'reused the container from last time' };
+ return { ok: false, detail: (started.stderr || '').trim() || 'could not start the existing container' };
+ }
+ return { ok: false, detail: message.trim().split('\n').pop() || 'docker could not start Redis' };
+}
+
+async function stepMemory(io, env, save, opts = {}) {
+ beginStep(3, 'Rumi\'s short-term memory');
+ console.log(ui.say('While a teacher is mid-conversation — halfway through a reading assessment, say — Rumi holds the thread in Redis. It also runs the slow work in the background, like marking a quiz.'));
+
+ if (!opts.reconfigure && hasAll(env, ['REDIS_URL'])) {
+ const check = await checkLive('Checking the Redis you already have…', 'redis', env, () => 'Short-term memory already connected');
+ if (check.ok) return;
+ console.log(ui.say('That one is not answering. Let us set it up again.'));
+ } else if (!opts.reconfigure && isTemplateSuggestion(env, 'REDIS_URL')) {
+ // The template's own suggestion (a local Redis). Worth trying, but silently:
+ // announcing a check for something the user never configured, and then
+ // failing it in red, reads as though setup is already broken.
+ const check = await probe('redis', env);
+ if (check.ok) {
+ console.log(ui.ok(`Short-term memory ready ${ui.dim(`(a Redis is already running at ${env.REDIS_URL})`)}`));
+ return;
+ }
+ }
+
+ // Injectable so both branches are testable on any machine. A spy on the
+ // export cannot reach this — the call is to the local binding — and a test
+ // that depends on whether the *test runner's* host has a Docker daemon is a
+ // test that passes for the wrong reason.
+ const hasDocker = (opts.dockerAvailable || dockerAvailable)();
+
+ const options = [];
+ if (hasDocker) {
+ options.push({ value: 'docker', label: 'Start one here with Docker', hint: 'One command, nothing to sign up for. Best for trying Rumi out.' });
+ }
+ options.push({ value: 'paste', label: 'I have an address to paste', hint: 'A Railway or Upstash instance, or a Redis you already run.' });
+
+ // Without Docker there is nothing to offer, so the question would be a
+ // one-item menu. Say where to *get* one instead — seen in a live fresh-clone
+ // run on a machine with no Docker daemon: the step asked for an address and
+ // explained the format, but never said how someone with no Redis at all was
+ // supposed to obtain one, which makes it a dead end rather than a step.
+ if (options.length === 1) {
+ console.log('');
+ console.log(ui.say('If you do not have one yet, either of these takes about two minutes:'));
+ console.log(ui.steps([
+ 'Free hosted: sign up at https://upstash.com, create a Redis database, and copy its redis:// URL',
+ 'On your own machine: install Docker, then `docker run -d -p 6379:6379 redis:7-alpine` and use redis://localhost:6379',
+ 'Already running one on a server? Paste its address — it only has to be reachable from here.',
+ ]));
+ }
+
+ let how = options.length > 1 ? await io.select('Where should Redis come from?', options, 'docker') : 'paste';
+
+ for (;;) {
+ if (how === 'docker') {
+ // One attempt only. Falling through to the paste prompt without clearing
+ // this would re-run `docker run` on every retry, so a bad pasted address
+ // would restart a container that had already failed.
+ how = 'paste';
+ const spin = ui.spinner('Starting Redis…');
+ const started = startLocalRedis();
+ if (started.ok) spin.succeed(`Redis running on your machine ${ui.dim(`(${started.detail})`)}`);
+ else spin.fail(`Docker could not start it: ${started.detail}`);
+ if (started.ok) {
+ save({ REDIS_URL: LOCAL_REDIS.url });
+ const check = await checkLive('Saying hello to Redis…', 'redis', env, () => 'Short-term memory ready');
+ if (check.ok) return;
+ }
+ console.log(ui.aside('Paste an address instead.'));
+ }
+
+ const url = await io.ask('Redis address', {
+ fallback: prefill(env, 'REDIS_URL') || LOCAL_REDIS.url,
+ validate: validators.redisUrl,
+ hint: 'Looks like redis://host:6379, or redis://default:password@host:6379 for a hosted one.',
+ });
+ save({ REDIS_URL: url });
+ const check = await checkLive('Saying hello to Redis…', 'redis', env, (d) => `Short-term memory ready ${ui.dim(d)}`);
+ if (check.ok) return;
+ console.log(ui.aside('No answer from there. If it is hosted, check the address is reachable from this machine (firewall, allowed IPs) and try again.'));
+ }
+}
+
+// ── Step 4: optional abilities ───────────────────────────────────────────────
+
+async function stepExtras(io, env, save, opts = {}) {
+ beginStep(4, 'Optional abilities');
+
+ const alreadyOn = fields.OPTIONAL_EXTRAS.filter((extra) => hasAll(env, extra.keys));
+ if (alreadyOn.length) {
+ console.log(ui.say('Already switched on:'));
+ for (const extra of alreadyOn) console.log(ui.bullet(extra.title));
+ console.log('');
+ }
+
+ const remaining = fields.OPTIONAL_EXTRAS.filter((extra) => !hasAll(env, extra.keys));
+ if (!remaining.length) {
+ console.log(ui.ok('Everything optional is already set up.'));
+ return;
+ }
+
+ console.log(ui.say('Rumi works without any of these. Each one adds something a teacher would notice, and you can add them later by running `rumi setup` again — so skipping is a real answer, not a postponement.'));
+
+ const choice = await io.select('Add any now?', [
+ { value: 'skip', label: 'Skip — get Rumi talking first', hint: 'Recommended. You can come back to this in two minutes.' },
+ { value: 'add', label: `Go through them (${remaining.length})`, hint: 'Press Enter on any you do not want.' },
+ ], opts.reconfigure ? 'add' : 'skip');
+
+ if (choice === 'skip') return;
+
+ for (const extra of remaining) {
+ console.log('');
+ console.log(` ${ui.paint('brandHi', extra.title)}`);
+ console.log(ui.aside(extra.why));
+ console.log(ui.aside(`Get a key: ${extra.where}`));
+ const collected = {};
+ for (const key of extra.keys) {
+ // The label is the env var only for the odd second field (a region, say)
+ // where there is no plainer name for it than the thing itself.
+ const label = key === extra.keys[0] ? 'Key' : key.replace(/_/g, ' ').toLowerCase();
+ // eslint-disable-next-line no-await-in-loop -- one question at a time, by design
+ const value = await io.ask(label, { secret: Boolean(extra.secret) && key === extra.keys[0], fallback: prefill(env, key) });
+ if (!value) break;
+ collected[key] = value;
+ }
+ if (Object.keys(collected).length === extra.keys.length) {
+ save(collected);
+ console.log(ui.ok(`${extra.title} — on`));
+ } else {
+ console.log(ui.dim(' skipped'));
+ }
+ }
+}
+
+// ── Step 5: WhatsApp ─────────────────────────────────────────────────────────
+
+/**
+ * The plain-language channel question. The technical value (`CHANNEL_DRIVER`)
+ * is decided behind it and never shown: "sandbox versus production driver" is
+ * our vocabulary, and asking it of a user makes them guess at an architecture
+ * they have no reason to know.
+ *
+ * @returns {Promise<'baileys'|'meta'>}
+ */
+async function chooseChannelDriver(io) {
+ return io.select('How are you using Rumi right now?', [
+ {
+ value: 'baileys',
+ label: 'Just trying it out — link my own WhatsApp',
+ hint: 'Two minutes, nothing to register. Works like WhatsApp Web.',
+ },
+ {
+ value: 'meta',
+ label: 'Real deployment — official WhatsApp Business number',
+ hint: 'Needs a Meta Business account and their review process.',
+ },
+ ], 'baileys');
+}
+
+const SANDBOX_CAVEATS = [
+ 'Rumi becomes a linked device on your own WhatsApp account, exactly like WhatsApp Web — so it can see and reply to your chats.',
+ 'Use a spare number or a second phone if that is not something you want.',
+ 'A few things are Meta-only and will feel plainer here: the tap-through forms, approved message templates, and the picture-menu carousels. Rumi asks the same questions as an ordinary chat instead, so nothing is blocked — but it is not the full experience.',
+ 'It is for trying Rumi out, not for a school: one personal account, and WhatsApp may disconnect it. Run `rumi graduate` for an official number as soon as you are past evaluating.',
+];
+
+async function linkSandbox(io) {
+ console.log('');
+ console.log(ui.say('Before you scan, two things worth knowing:'));
+ for (const caveat of SANDBOX_CAVEATS) console.log(ui.bullet(caveat, { dim: true }));
+ console.log('');
+
+ const ready = await io.confirm('Ready to scan the code?', true);
+ if (!ready) {
+ console.log(ui.aside('No problem — run `rumi pair` whenever you are.'));
+ return { linked: false };
+ }
+
+ console.log('');
+ console.log(ui.say('On your phone: WhatsApp → Settings → Linked devices → Link a device, then point it at the code below.'));
+ console.log('');
+
+ const { linkWhatsApp, releaseWhatsApp } = require('./link-whatsapp');
+ const result = await linkWhatsApp();
+ await releaseWhatsApp();
+
+ if (result.ok) {
+ console.log('');
+ console.log(ui.ok(`Linked${result.number ? ` as ${ui.bold(`+${result.number}`)}` : ''}`));
+ return { linked: true, number: result.number };
+ }
+
+ const explanation = {
+ timeout: 'Nothing was scanned in time.',
+ 'logged-out': 'WhatsApp rejected the session.',
+ busy: 'Rumi already seems to be running, and two processes cannot share one WhatsApp session. Stop the other one first.',
+ }[result.reason] || `Pairing failed: ${result.detail || 'unknown error'}`;
+ console.log('');
+ console.log(ui.warn(`${explanation} Everything else is saved — run \`rumi pair\` to try again.`));
+ return { linked: false };
+}
+
+async function collectMetaCredentials(io, env, save) {
+ console.log('');
+ console.log(ui.say('Four values from Meta, all on one page.'));
+ console.log('');
+ console.log(ui.steps(fields.META_WALKTHROUGH));
+
+ for (const field of fields.META_FIELDS) {
+ console.log('');
+ const existing = env[field.env] && !/^CHANGEME/i.test(env[field.env]) ? env[field.env] : '';
+ // eslint-disable-next-line no-await-in-loop -- one question at a time, by design
+ const value = await io.ask(field.label, {
+ hint: field.hint,
+ secret: Boolean(field.secret),
+ fallback: existing || (field.generate ? field.generate() : ''),
+ validate: field.validate,
+ });
+ save({ [field.env]: value });
+ }
+
+ const check = await checkLive('Asking Meta whether those work…', 'whatsapp', env, (d) => `Meta accepted your credentials ${ui.dim(d)}`);
+ if (!check.ok) {
+ console.log(ui.aside('Meta rejected them. The commonest cause is an access token that has expired — they last 24 hours unless you created a permanent one. Re-run `rumi setup --reconfigure` once you have a fresh token; everything else is saved.'));
+ }
+ return check.ok;
+}
+
+/**
+ * Is the channel in `.env` already usable? Answering this is what keeps a
+ * re-run from offering to re-pair a WhatsApp that is working perfectly well —
+ * and re-pairing is not harmless: scanning a new code while the bot holds the
+ * session is how a session gets invalidated.
+ *
+ * @returns {Promise<{number?: string|null, detail?: string}|null>} null when it is not
+ */
+async function channelAlreadyWorking(env, channel) {
+ if (channel === 'meta') {
+ if (!hasAll(env, ['WHATSAPP_TOKEN', 'PHONE_NUMBER_ID'])) return null;
+ const check = await probe('whatsapp', env);
+ return check.ok ? { detail: check.detail } : null;
+ }
+ const { sandboxIdentity } = require('./status');
+ const identity = sandboxIdentity(env);
+ return identity.paired ? { number: identity.number } : null;
+}
+
+async function stepChannel(io, env, save, opts = {}) {
+ beginStep(5, 'Connecting WhatsApp');
+
+ // Only an explicit CHANNEL_DRIVER counts as "already chosen" — the runtime
+ // infers a default when it is unset, and inheriting that silently would skip
+ // the one question this step exists to ask.
+ const existing = isProvided(env, 'CHANNEL_DRIVER') ? env.CHANNEL_DRIVER.trim().toLowerCase() : '';
+ if (existing && !opts.reconfigure) {
+ const spin = ui.spinner('Checking the WhatsApp connection you already have…');
+ const working = await channelAlreadyWorking(env, existing);
+ if (working) {
+ spin.succeed(working.number
+ ? `WhatsApp already linked as ${ui.bold(`+${working.number}`)}`
+ : `WhatsApp already connected ${ui.dim(working.detail || '')}`);
+ return { channel: existing, linked: true, number: working.number };
+ }
+ spin.stop();
+ }
+
+ const channel = await chooseChannelDriver(io);
+ const vars = { CHANNEL_DRIVER: channel };
+
+ // The async pipeline (quiz reports, coaching, video) needs a queue, and the
+ // template's default is `sqs` — an AWS account a sandbox user does not have.
+ // Seen live: a quiz that generated AND delivered still told the teacher
+ // "something went wrong", because scheduling its report threw "SQS Queue not
+ // configured". bullmq runs on the Redis collected in step 3, so it is the
+ // only sensible sandbox default. Production keeps its own queue choice.
+ if (channel !== 'meta') {
+ vars.QUEUE_DRIVER = 'bullmq';
+ vars.CHANNEL_STATE_DIR = env.CHANNEL_STATE_DIR || '.channel-state';
+ }
+ save(vars);
+
+ if (channel === 'meta') {
+ await collectMetaCredentials(io, env, save);
+ return { channel, linked: false };
+ }
+ const outcome = await linkSandbox(io);
+ return { channel, ...outcome };
+}
+
+// ── Screens ──────────────────────────────────────────────────────────────────
+
+function welcome(env) {
+ console.log(ui.logo('An AI teaching companion that lives in WhatsApp'));
+ console.log(ui.say('This sets Rumi up on your own accounts, start to finish. It takes about fifteen minutes, most of which is waiting for a database to start.'));
+ console.log('');
+ console.log(ui.bold(' You will need'));
+ console.log(ui.bullet('A free Supabase account — where Rumi remembers things'));
+ console.log(ui.bullet('An OpenRouter account with a few dollars of credit — how Rumi thinks'));
+ console.log(ui.bullet('WhatsApp on your phone'));
+ console.log('');
+ console.log(ui.bold(' Good to know'));
+ console.log(ui.bullet('Each answer is saved as you go. Press Ctrl+C to stop and run `rumi setup` again to carry on.'));
+ console.log(ui.bullet('Keys are hidden while you type, and stay on this machine in a file called .env.'));
+
+ const done = ['SUPABASE_URL', 'OPENROUTER_API_KEY', 'REDIS_URL'].filter((key) => isProvided(env, key));
+ if (done.length) {
+ console.log('');
+ console.log(ui.say(`Picking up from last time — ${done.length} of 3 core services are already configured, so this will be quick.`));
+ }
+}
+
+async function finish(env, channelResult) {
+ const { channel, number, linked } = channelResult;
+ console.log('');
+ console.log(ui.rule());
+ const spin = ui.spinner('One last check of everything…');
+ const { runDoctor } = require('./doctor');
+ const doctor = await runDoctor({ env });
+ spin.stop();
+
+ console.log(ui.logo());
+ const headline = doctor.ok
+ ? ui.paint('brand', 'Rumi is ready.', { bold: true })
+ : ui.paint('accent', 'Rumi is set up, with something still to fix.', { bold: true });
+ console.log(` ${headline}`);
+ console.log('');
+ console.log(summary.renderReadiness(doctor, { number, linked }));
+ console.log('');
+ console.log(ui.rule());
+ console.log('');
+ console.log(summary.renderNextSteps({ channel, number }));
+ console.log('');
+ if (!doctor.ok) {
+ console.log(ui.aside('Run `rumi doctor` for the detail on what is not working yet.'));
+ console.log('');
+ }
+}
+
+// ── Entry point ──────────────────────────────────────────────────────────────
+
+async function main(argv = process.argv) {
+ try { require('dotenv').config({ path: ENV_PATH, quiet: true }); } catch { /* dotenv optional */ }
+
+ const reconfigure = argv.includes('--reconfigure') || argv.includes('--force');
+ const env = { ...process.env, ...readEnvFile(ENV_PATH) };
+ const save = createSaver(env);
+ const io = createIo();
+ const opts = { reconfigure };
+
+ // Handled here rather than at the process entry point, because the wizard is
+ // launched two ways (`rumi setup` and `node interactive-setup.js`) and a
+ // goodbye that only works one of them is how Ctrl+C ended up printing a bare
+ // "Cancelled by user" stack-trace line through the CLI.
+ try {
+ welcome(env);
+ console.log('');
+ await io.pressEnter('Press Enter to begin');
+
+ await stepDatabase(io, env, save, opts);
+ finishStep('Where Rumi keeps its memory');
+ await stepBrain(io, env, save, opts);
+ finishStep('How Rumi thinks');
+ await stepMemory(io, env, save, opts);
+ finishStep("Rumi's short-term memory");
+ await stepExtras(io, env, save, opts);
+ finishStep('Optional abilities');
+ const channelResult = await stepChannel(io, env, save, opts);
+
+ await finish(env, channelResult);
+ } catch (err) {
+ if (err instanceof PromptAbortError || err.aborted) {
+ console.log('');
+ console.log(ui.say('Stopped. Everything you answered is saved — run `rumi setup` again to carry on from here.'));
+ process.exitCode = 130;
+ return;
+ }
+ console.log('');
+ console.log(ui.fail(`Setup could not finish: ${err.message}`));
+ console.log(ui.aside('Nothing already saved was lost. `rumi doctor` shows where things stand.'));
+ process.exitCode = 1;
+ }
+}
+
+if (require.main === module) {
+ main().then(() => process.exit(process.exitCode || 0));
+}
+
+module.exports = {
+ main, welcome, finish,
+ stepDatabase, stepBrain, stepMemory, stepExtras, stepChannel,
+ ensureTables, chooseChannelDriver, collectMetaCredentials, linkSandbox, channelAlreadyWorking,
+ createSaver, hasAll, isProvided, prefill, isTemplateSuggestion, startLocalRedis, dockerAvailable, probe,
+ beginStep, finishStep,
+ TOTAL_STEPS, LOCAL_REDIS,
+};
diff --git a/bot/scripts/setup/link-whatsapp.js b/bot/scripts/setup/link-whatsapp.js
new file mode 100644
index 0000000..98cae90
--- /dev/null
+++ b/bot/scripts/setup/link-whatsapp.js
@@ -0,0 +1,137 @@
+/**
+ * link-whatsapp.js — pairs the sandbox channel by QR, shared by `rumi setup`
+ * (as its last step) and `rumi pair` (to re-link later).
+ *
+ * It deliberately drives the *same* connection module the running bot uses
+ * (`shared/services/messaging/baileys-connection`) rather than opening a socket
+ * of its own. Two code paths writing one WhatsApp session is how a session gets
+ * invalidated, and the recovery is manual re-pairing — so there is exactly one
+ * place that ever touches it.
+ *
+ * @module link-whatsapp
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+/** A human has to pick up a phone, find Linked Devices, and scan. Be generous. */
+const DEFAULT_TIMEOUT_MS = 150_000;
+
+/** "923001234567:12@s.whatsapp.net" → "923001234567" */
+function numberFromId(id) {
+ if (typeof id !== 'string') return null;
+ const digits = id.split(':')[0].split('@')[0].replace(/\D/g, '');
+ return digits || null;
+}
+
+/**
+ * The linked account's number, so the caller can say *which* account got linked
+ * rather than just "done".
+ *
+ * @param {object} sock
+ * @returns {string|null}
+ */
+function linkedNumber(sock) {
+ return numberFromId(sock && sock.user && sock.user.id);
+}
+
+/**
+ * The same, read from the stored session on disk.
+ *
+ * This exists because the socket is not a reliable source at the moment pairing
+ * completes. `connection.events.emit('open')` fires *synchronously* just before
+ * getSocket()'s promise resolves, so a `.then()` that captures the socket has
+ * not run yet — and immediately after a fresh pairing Baileys tears the socket
+ * down with "restart required" and reconnects with a different one anyway. Seen
+ * live: a successful pairing reported "✔ Linked" with no number, and the
+ * closing screen then said "not linked yet".
+ *
+ * @param {object} connection
+ * @returns {string|null}
+ */
+function storedNumber(connection) {
+ try {
+ const credsPath = path.join(connection.authDir(), 'creds.json');
+ const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
+ return numberFromId(creds && creds.me && creds.me.id);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Opens the connection, prints a QR if pairing is needed, and resolves once
+ * WhatsApp reports the link is live.
+ *
+ * @param {object} [opts]
+ * @param {Function} [opts.onQr] called the first time a QR is rendered
+ * @param {number} [opts.timeoutMs]
+ * @param {object} [opts.connection] injectable for tests
+ * @returns {Promise<{ok: boolean, number?: string, reason?: string, detail?: string}>}
+ */
+async function linkWhatsApp(opts = {}) {
+ const connection = opts.connection || require('../../shared/services/messaging/baileys-connection');
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
+
+ let sock = null;
+ let settled = false;
+ let onOpen;
+ let onClose;
+
+ const outcome = await new Promise((resolve) => {
+ const timer = setTimeout(() => settle({ ok: false, reason: 'timeout' }), timeoutMs);
+ if (typeof timer.unref === 'function') timer.unref();
+
+ function settle(value) {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(value);
+ }
+
+ // Subscribed on `connection.events`, which survives the internal reconnect
+ // Baileys forces immediately after a fresh pairing ("restart required").
+ // A listener on the first socket alone would miss the second, real open.
+ onOpen = () => settle({ ok: true });
+ onClose = ({ loggedOut }) => {
+ if (loggedOut) settle({ ok: false, reason: 'logged-out' });
+ // Any other close is the expected post-pairing restart — the connection
+ // module reconnects on its own and the timeout is the backstop.
+ };
+ connection.events.on('open', onOpen);
+ connection.events.on('close', onClose);
+
+ connection
+ // `allowRepair` lets a QR appear even when a now-invalid creds.json is
+ // still on disk — exactly the state re-pairing starts from. The bot never
+ // passes it, so it still refuses to sit in a QR loop unattended.
+ .getSocket({ allowRepair: true, onQr: opts.onQr })
+ .then((opened) => { sock = opened; })
+ .catch((err) => {
+ const busy = /already using|instance lock/i.test(err.message);
+ settle({ ok: false, reason: busy ? 'busy' : 'error', detail: err.message });
+ });
+ });
+
+ connection.events.removeListener('open', onOpen);
+ connection.events.removeListener('close', onClose);
+
+ if (!outcome.ok) return outcome;
+ return { ok: true, number: linkedNumber(sock) || storedNumber(connection) };
+}
+
+/**
+ * Hands the WhatsApp session back. A paired socket keeps the event loop alive,
+ * so a wizard that forgets this appears to hang after saying it succeeded.
+ */
+async function releaseWhatsApp(connection = require('../../shared/services/messaging/baileys-connection')) {
+ try {
+ await connection.close();
+ } catch {
+ // Already closed, or never opened — nothing to hand back.
+ }
+}
+
+module.exports = {
+ linkWhatsApp, releaseWhatsApp, linkedNumber, storedNumber, numberFromId, DEFAULT_TIMEOUT_MS,
+};
diff --git a/bot/scripts/setup/prompt.js b/bot/scripts/setup/prompt.js
new file mode 100644
index 0000000..65c22cf
--- /dev/null
+++ b/bot/scripts/setup/prompt.js
@@ -0,0 +1,299 @@
+/**
+ * prompt.js — the input side of the `rumi` CLI.
+ *
+ * Exposes one object (`createIo()`) with four question shapes: `ask`,
+ * `secret`, `select`, `confirm`. Everything the wizard asks goes through it,
+ * which is what makes two guarantees hold everywhere at once:
+ *
+ * - **A pasted secret never lands in scrollback.** Keys are read character by
+ * character in raw mode and echoed as dots. A terminal history full of
+ * service-role keys is a real leak, and the person setting Rumi up for the
+ * first time is the least likely to notice it happened.
+ * - **Ctrl+C is an answer, not a crash.** Every reader rejects with an
+ * `aborted` error and restores the terminal (raw mode off, cursor back)
+ * before it does, so the caller can say goodbye properly instead of the
+ * shell being left in raw mode with no cursor.
+ *
+ * Tests pass their own object with the same four methods rather than
+ * simulating keystrokes — see tests/setup/interactive-setup.test.js.
+ *
+ * @module prompt
+ */
+
+const readline = require('readline');
+const ui = require('./ui');
+
+const CURSOR_HIDE = '\u001b[?25l';
+const CURSOR_SHOW = '\u001b[?25h';
+const CLEAR_BELOW = '\u001b[0J';
+const MASK_CHAR = '•';
+
+/** Thrown by every reader when the user presses Ctrl+C. */
+class PromptAbortError extends Error {
+ constructor() {
+ super('Cancelled by user');
+ this.name = 'PromptAbortError';
+ this.aborted = true;
+ }
+}
+
+const isTty = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
+
+// ── Raw-mode plumbing ────────────────────────────────────────────────────────
+
+/**
+ * Runs `handler` with stdin in raw mode, guaranteeing the terminal is handed
+ * back exactly as it was found — including on a throw. Every raw reader below
+ * goes through here so there is one place that can leave a shell broken, and
+ * it is only a few lines long.
+ *
+ * @param {(emit: {resolve: Function, reject: Function}) => (chunk: string) => void} attach
+ * @returns {Promise}
+ */
+function withRawStdin(attach) {
+ return new Promise((resolve, reject) => {
+ const stdin = process.stdin;
+ const wasRaw = stdin.isRaw;
+ let settled = false;
+
+ const restore = () => {
+ stdin.removeListener('data', onData);
+ if (stdin.setRawMode) stdin.setRawMode(wasRaw);
+ stdin.pause();
+ };
+ const settle = (fn, value) => {
+ if (settled) return;
+ settled = true;
+ restore();
+ fn(value);
+ };
+
+ const onData = attach({
+ resolve: (value) => settle(resolve, value),
+ reject: (err) => settle(reject, err),
+ });
+
+ if (stdin.setRawMode) stdin.setRawMode(true);
+ stdin.resume();
+ stdin.setEncoding('utf8');
+ stdin.on('data', onData);
+ });
+}
+
+/** One line of ordinary, echoed input. */
+function readLine(promptText) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: isTty(),
+ });
+ return new Promise((resolve, reject) => {
+ rl.once('SIGINT', () => { rl.close(); reject(new PromptAbortError()); });
+ rl.question(promptText, (answer) => { rl.close(); resolve(answer); });
+ });
+}
+
+/**
+ * One line of input echoed as dots. Falls back to plain `readLine` when there
+ * is no TTY to control (piped input, CI) — masking is a courtesy to the
+ * scrollback, never a reason to refuse to read.
+ */
+function readSecret(promptText) {
+ if (!isTty()) return readLine(promptText);
+ process.stdout.write(promptText);
+
+ return withRawStdin(({ resolve, reject }) => {
+ let value = '';
+ const erase = (count) => process.stdout.write('\b \b'.repeat(count));
+
+ return (chunk) => {
+ for (const char of chunk) {
+ if (char === '\r' || char === '\n' || char === '\u0004') {
+ process.stdout.write('\n');
+ resolve(value);
+ return;
+ }
+ if (char === '\u0003') { // Ctrl+C
+ process.stdout.write('\n');
+ reject(new PromptAbortError());
+ return;
+ }
+ if (char === '\u007f' || char === '\b') {
+ if (value) { value = value.slice(0, -1); erase(1); }
+ } else if (char === '\u0015') { // Ctrl+U — clear the line
+ erase(value.length);
+ value = '';
+ } else if (char >= ' ') {
+ value += char;
+ process.stdout.write(MASK_CHAR);
+ }
+ // Anything else is a control byte (an escape sequence from an arrow
+ // key, say) — dropped rather than pasted into a credential.
+ }
+ };
+ });
+}
+
+// ── Selection menu ───────────────────────────────────────────────────────────
+
+/**
+ * An arrow-key menu. Options are `{ label, value, hint }`; the selected
+ * option's hint is shown under the list, so the consequence of a choice is
+ * visible before it's made rather than after.
+ *
+ * Without a TTY this degrades to a numbered list read from stdin — the same
+ * question, still answerable by a script or a pipe.
+ *
+ * @param {string} question
+ * @param {Array<{label: string, value: string, hint?: string}>} options
+ * @param {number} defaultIndex
+ * @returns {Promise} the chosen option's `value`
+ */
+async function readChoice(question, options, defaultIndex) {
+ console.log(`\n${ui.arrow(ui.bold(question))}`);
+
+ if (!isTty()) {
+ options.forEach((opt, i) => {
+ const marker = i === defaultIndex ? ui.paint('brand', '●') : ui.dim('○');
+ console.log(` ${marker} ${i + 1}. ${opt.label}`);
+ });
+ const answer = (await readLine(` ${ui.dim(`choose 1-${options.length}`)} [${defaultIndex + 1}]: `)).trim();
+ if (!answer) return options[defaultIndex].value;
+ const index = Number.parseInt(answer, 10) - 1;
+ return options[index] ? options[index].value : options[defaultIndex].value;
+ }
+
+ let cursor = defaultIndex;
+ // Lines drawn below the option list: one blank, the selected option's hint,
+ // and the key legend. A redraw rewinds the cursor by exactly the number of
+ // lines written, so this count and `render` must not drift apart — hence
+ // `fit` below: a label that wrapped would occupy two lines, and every redraw
+ // after it would start eating the line above.
+ const TRAILING_LINES = 3;
+ const room = ui.measure() - 6;
+ const fit = (text) => (ui.visibleWidth(text) <= room ? text : `${text.slice(0, room - 1)}…`);
+
+ const render = (first) => {
+ if (!first) process.stdout.write(`\u001b[${options.length + TRAILING_LINES}A`);
+ process.stdout.write(CLEAR_BELOW);
+ options.forEach((opt, i) => {
+ const label = fit(opt.label);
+ const line = i === cursor
+ ? `${ui.paint('brand', '❯')} ${ui.paint('brand', label, { bold: true })}`
+ : ` ${ui.dim(label)}`;
+ process.stdout.write(` ${line}\n`);
+ });
+ process.stdout.write(`\n ${ui.dim(fit(options[cursor].hint || ''))}\n`);
+ process.stdout.write(` ${ui.dim('↑↓ move · Enter to choose')}\n`);
+ };
+
+ process.stdout.write(CURSOR_HIDE);
+ render(true);
+ try {
+ return await withRawStdin(({ resolve, reject }) => (chunk) => {
+ for (let i = 0; i < chunk.length; i += 1) {
+ const char = chunk[i];
+ if (char === '\u0003') { reject(new PromptAbortError()); return; }
+ if (char === '\r' || char === '\n') { resolve(options[cursor].value); return; }
+ if (char === '\u001b' && chunk[i + 1] === '[') {
+ const code = chunk[i + 2];
+ if (code === 'A') cursor = (cursor - 1 + options.length) % options.length;
+ if (code === 'B') cursor = (cursor + 1) % options.length;
+ i += 2;
+ render(false);
+ continue;
+ }
+ if (char === 'k') { cursor = (cursor - 1 + options.length) % options.length; render(false); }
+ if (char === 'j') { cursor = (cursor + 1) % options.length; render(false); }
+ const digit = Number.parseInt(char, 10);
+ if (Number.isInteger(digit) && options[digit - 1]) { cursor = digit - 1; render(false); }
+ }
+ });
+ } finally {
+ process.stdout.write(CURSOR_SHOW);
+ }
+}
+
+// ── The io facade ────────────────────────────────────────────────────────────
+
+/**
+ * Renders the "[current value]" part of a prompt. Secrets show only their
+ * first and last few characters: enough to recognise which key is already
+ * there, not enough to reconstruct it from a screen-share.
+ */
+function previewOf(value, secret) {
+ if (!value) return '';
+ if (!secret) return value;
+ return value.length <= 12 ? '•'.repeat(value.length) : `${value.slice(0, 4)}…${value.slice(-4)}`;
+}
+
+/**
+ * @typedef {object} AskOptions
+ * @property {string} [hint] one line of context printed above the field
+ * @property {string} [fallback] value used when the user just presses Enter
+ * @property {boolean} [secret] read masked, and preview the default masked
+ * @property {(value: string) => ({ok: boolean, reason?: string, value?: string})} [validate]
+ */
+
+/**
+ * Builds the object the wizard talks to. Holds no long-lived handle on stdin:
+ * each question opens its own reader and closes it, which is what lets a
+ * masked read and an ordinary line read sit next to each other without
+ * fighting over the stream.
+ */
+function createIo() {
+ return {
+ /**
+ * Ask for a value, re-asking until `validate` accepts it. A validator may
+ * return a cleaned `value` (trimmed URL, stripped quotes) which is what
+ * gets stored — the user is not asked to paste tidily.
+ *
+ * @param {string} label
+ * @param {AskOptions} [opts]
+ * @returns {Promise}
+ */
+ async ask(label, opts = {}) {
+ const { hint, fallback = '', secret = false, validate } = opts;
+ if (hint) console.log(ui.aside(hint));
+
+ for (;;) {
+ const preview = previewOf(fallback, secret);
+ const suffix = preview ? ui.dim(` [${preview}]`) : '';
+ const promptText = ` ${ui.paint('accent', '›')} ${label}${suffix}: `;
+ const raw = secret ? await readSecret(promptText) : await readLine(promptText);
+ const answer = (raw || '').trim() || fallback;
+
+ if (!validate) return answer;
+ const verdict = validate(answer);
+ if (verdict.ok) return verdict.value === undefined ? answer : verdict.value;
+ console.log(ui.aside(ui.paint('danger', verdict.reason)));
+ }
+ },
+
+ /** @returns {Promise} */
+ async confirm(question, defaultYes = true) {
+ const hint = defaultYes ? 'Y/n' : 'y/N';
+ const answer = (await readLine(` ${ui.paint('accent', '›')} ${question} ${ui.dim(`[${hint}]`)} `)).trim().toLowerCase();
+ if (!answer) return defaultYes;
+ return answer.startsWith('y');
+ },
+
+ /**
+ * @param {string} question
+ * @param {Array<{label: string, value: string, hint?: string}>} options
+ * @param {string} [defaultValue]
+ * @returns {Promise}
+ */
+ async select(question, options, defaultValue) {
+ const defaultIndex = Math.max(0, options.findIndex((o) => o.value === defaultValue));
+ return readChoice(question, options, defaultIndex);
+ },
+
+ /** Waits for Enter — used to hold the wizard while the user does something in a browser. */
+ async pressEnter(text = 'Press Enter to continue') {
+ await readLine(` ${ui.dim(text)} `);
+ },
+ };
+}
+
+module.exports = { createIo, PromptAbortError, readLine, readSecret, readChoice, previewOf };
diff --git a/bot/scripts/setup/status.js b/bot/scripts/setup/status.js
new file mode 100644
index 0000000..1e67852
--- /dev/null
+++ b/bot/scripts/setup/status.js
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+/**
+ * status.js — `rumi status`, the "what is going on right now" view.
+ *
+ * Distinct from `rumi doctor` on purpose. Doctor answers *is each service
+ * reachable* — a checklist you read when something is broken. Status answers
+ * the two questions someone actually has after setup: **is Rumi running**, and
+ * **which WhatsApp account is it answering as**. Neither is visible in a
+ * credentials checklist, and both are what you want before sending a test
+ * message.
+ *
+ * Everything here is read from disk plus doctor's own probes; nothing is
+ * started, stopped or changed.
+ *
+ * @module status
+ */
+
+// A `rumi` command is a conversation with a person, so its output must stay
+// human-readable. bot/shared/utils/structured-logger.js replaces console.* with
+// JSON logging for the server, and any module that reaches it (the WhatsApp
+// connection does) would take this command's output with it — a QR code and a
+// wizard, rendered as log records. Set before the first require, since the
+// override happens at import time.
+process.env.RUMI_CLI = '1';
+
+const fs = require('fs');
+const path = require('path');
+
+const ui = require('./ui');
+const summary = require('./summary');
+const { readEnvFile } = require('./env-file');
+
+const ROOT = path.resolve(__dirname, '../../..');
+const ENV_PATH = path.join(ROOT, '.env');
+
+/** True when a process with this pid exists and we are allowed to signal it. */
+function pidIsAlive(pid) {
+ if (!Number.isInteger(pid) || pid <= 0) return false;
+ try {
+ process.kill(pid, 0); // signal 0 = existence check only
+ return true;
+ } catch (err) {
+ return err.code === 'EPERM'; // exists, owned by someone else
+ }
+}
+
+/**
+ * Is a Rumi process holding the WhatsApp session?
+ *
+ * The lock file is written by the connection module for a completely different
+ * reason — stopping two processes from sharing (and thereby destroying) one
+ * WhatsApp session — but it happens to be the only honest local answer to "is
+ * the bot up", so status reads it rather than inventing a second pid file that
+ * could disagree.
+ *
+ * @param {object} env
+ * @returns {{running: boolean, pid?: number, since?: string, stale?: boolean}}
+ */
+function processState(env) {
+ const stateDir = env.CHANNEL_STATE_DIR || '.channel-state';
+ const lockFile = path.resolve(ROOT, stateDir, 'baileys', '.instance.lock');
+ let holder;
+ try {
+ holder = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
+ } catch {
+ return { running: false };
+ }
+ if (!pidIsAlive(holder.pid)) return { running: false, stale: true, pid: holder.pid };
+ return { running: true, pid: holder.pid, since: holder.since };
+}
+
+/**
+ * Which WhatsApp account the stored sandbox session belongs to, read straight
+ * out of the saved credentials — so it answers even when Rumi is not running.
+ *
+ * @param {object} env
+ * @returns {{paired: boolean, number?: string|null, name?: string}}
+ */
+function sandboxIdentity(env) {
+ const stateDir = env.CHANNEL_STATE_DIR || '.channel-state';
+ const credsFile = path.resolve(ROOT, stateDir, 'baileys', 'creds.json');
+ try {
+ const creds = JSON.parse(fs.readFileSync(credsFile, 'utf-8'));
+ const id = creds && creds.me && creds.me.id;
+ const number = typeof id === 'string' ? id.split(':')[0].split('@')[0].replace(/\D/g, '') : null;
+ return { paired: Boolean(number), number, name: creds && creds.me && creds.me.name };
+ } catch {
+ return { paired: false };
+ }
+}
+
+/**
+ * @param {object} state from processState()
+ * @param {boolean} [localOnly] false for a channel whose bot normally runs elsewhere
+ */
+function renderProcessLine(state, localOnly = true) {
+ if (state.running) {
+ const since = state.since ? ui.dim(` since ${new Date(state.since).toLocaleString()}`) : '';
+ return `${ui.paint('brand', 'running')} ${ui.dim(`pid ${state.pid}`)}${since}`;
+ }
+ if (state.stale) return `${ui.dim('not running')} ${ui.dim(`(pid ${state.pid} is gone — a stale lock, harmless)`)}`;
+ // On Meta the bot runs on a host somewhere, not here. Saying "not running"
+ // would be a confident claim about a machine this command cannot see.
+ if (!localOnly) return ui.dim('nothing running on this machine — check your host\'s logs for the deployed one');
+ return ui.dim('not running — start it with `rumi start`');
+}
+
+async function main() {
+ try { require('dotenv').config({ path: ENV_PATH, quiet: true }); } catch { /* dotenv optional */ }
+ const env = { ...process.env, ...readEnvFile(ENV_PATH) };
+
+ const { runDoctor } = require('./doctor');
+ const { isProductionTier } = require('../../shared/services/messaging/channel-registry');
+
+ console.log(ui.logo());
+ const spin = ui.spinner('Checking…');
+ const doctor = await runDoctor({ env });
+ spin.stop();
+
+ const tier = isProductionTier(doctor.channel) ? 'official WhatsApp Business number' : 'linked to your own WhatsApp';
+ console.log(` ${ui.bold(`Rumi · ${doctor.channel}`)} ${ui.dim(`— ${tier}`)}`);
+ console.log('');
+
+ const isLocalChannel = doctor.channel === 'baileys';
+ const identity = isLocalChannel ? sandboxIdentity(env) : { paired: false };
+ console.log(ui.table([['Process', renderProcessLine(processState(env), isLocalChannel)]]));
+ console.log('');
+ console.log(summary.renderReadiness(doctor, { number: identity.number }));
+ console.log('');
+ console.log(doctor.ok
+ ? ui.ok('Everything Rumi needs is working.')
+ : ui.warn('Something is not working — `rumi doctor` has the detail.'));
+ console.log('');
+
+ process.exitCode = doctor.ok ? 0 : 1;
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.log(ui.fail(`Could not read the status: ${err.message}`));
+ process.exitCode = 1;
+ });
+}
+
+module.exports = {
+ main, processState, sandboxIdentity, pidIsAlive, renderProcessLine,
+};
diff --git a/bot/scripts/setup/summary.js b/bot/scripts/setup/summary.js
new file mode 100644
index 0000000..908c2dc
--- /dev/null
+++ b/bot/scripts/setup/summary.js
@@ -0,0 +1,148 @@
+/**
+ * summary.js — how a Rumi deployment's state is shown to a person.
+ *
+ * `rumi doctor` already answers "is everything reachable" for an operator
+ * debugging a deployment; its output is a diagnostic, and reads like one. This
+ * module answers a different question — "am I ready, and what do I do next" —
+ * for someone who has just finished setting Rumi up and has never seen it run.
+ * Both read the same `runDoctor()` result, so they cannot disagree about facts.
+ *
+ * Shared by the wizard's closing screen and `rumi status`, which is why the
+ * "linked as +…" line and the on/off table live here rather than in either.
+ *
+ * @module summary
+ */
+
+const ui = require('./ui');
+
+/** Doctor names features for precision ("Voice notes (speech-to-text, Soniox)"); a
+ * closing screen wants the plain half. The vendor is still shown, via the env
+ * var you would add to switch the feature on. */
+const shortFeatureName = (name) => String(name).replace(/\s*\(.*\)\s*$/, '');
+
+/**
+ * The one-line-per-thing readiness table.
+ *
+ * Off is stated as an invitation, not a failure — everything in the optional
+ * list is genuinely optional, and a first-time setup that reports five red
+ * crosses for features the user chose to skip teaches them to ignore the
+ * output.
+ *
+ * @param {object} doctor a runDoctor() result
+ * @param {{number?: string|null}} [opts]
+ * @returns {string}
+ */
+function renderReadiness(doctor, opts = {}) {
+ const rows = [];
+
+ for (const probe of doctor.probeResults) {
+ if (probe.status === 'skip') continue;
+ const label = ({
+ Supabase: 'Memory (database)',
+ 'OpenRouter (LLM)': 'Thinking (AI)',
+ Redis: 'Short-term memory',
+ 'WhatsApp Cloud API': 'WhatsApp',
+ })[probe.name] || probe.name;
+ const extra = detailSuffix(probe.detail);
+ rows.push([label, probe.status === 'pass'
+ ? ui.paint('brand', 'ready') + (extra ? ui.dim(extra) : '')
+ : ui.paint('danger', `not working — ${probe.detail}`)]);
+ }
+
+ if (doctor.channel === 'baileys') {
+ // Keyed on `linked`, not on having a number: a pairing can succeed without
+ // us learning the number (see link-whatsapp.js), and telling someone who
+ // just scanned a code that they are "not linked yet" is the worst kind of
+ // wrong — it contradicts the ✔ two lines above it.
+ const linked = opts.linked === undefined ? Boolean(opts.number) : opts.linked;
+ if (!linked) rows.push(['WhatsApp', ui.paint('accent', 'not linked yet — run `rumi pair`')]);
+ else if (opts.number) rows.push(['WhatsApp', `${ui.paint('brand', 'linked')} ${ui.dim(`as +${opts.number}`)}`]);
+ else rows.push(['WhatsApp', ui.paint('brand', 'linked')]);
+ }
+
+ const on = doctor.featureResults.filter((f) => f.status === 'on');
+ const off = doctor.featureResults.filter((f) => f.status !== 'on');
+
+ const lines = [ui.table(rows)];
+ if (on.length) {
+ lines.push('', ui.dim(' Also switched on'));
+ lines.push(on.map((f) => ui.bullet(shortFeatureName(f.name))).join('\n'));
+ }
+ if (off.length) {
+ lines.push('', ui.dim(' Available later — add the key and Rumi picks it up on restart'));
+ // A table rather than a bulleted list: the keys line up into a column you
+ // can read down, which is how someone decides what to add next.
+ lines.push(ui.table(off.map((f) => {
+ const key = (f.missingKeys && f.missingKeys[0]) || (f.requiredKeys && f.requiredKeys[0]) || '';
+ return [shortFeatureName(f.name), ui.dim(key)];
+ }), { indent: 4 }));
+ }
+ return lines.join('\n');
+}
+
+/** Keeps a useful probe detail (a credit balance) and drops the noise (HTTP 200). */
+function detailSuffix(detail) {
+ const text = String(detail || '');
+ const credit = /\$[\d.]+ credit remaining/.exec(text);
+ if (credit) return ` ${credit[0]}`;
+ return '';
+}
+
+/**
+ * What to actually do now. Written as the shortest path to seeing Rumi work,
+ * because the moment after setup is the one where a person decides whether this
+ * thing is real.
+ *
+ * @param {{channel: string, number?: string|null}} opts
+ * @returns {string}
+ */
+function renderNextSteps(opts) {
+ const lines = [];
+ lines.push(ui.bold(' Start Rumi'));
+ // `rumi start` rather than `cd bot && npm start`: the latter runs the bot from
+ // bot/, where a relative .env and a relative session folder both resolved to
+ // the wrong place — the bot aborted on "missing required vars", and when it did
+ // boot it paired a second WhatsApp device and re-synced forever.
+ lines.push(` ${ui.paint('brandHi', 'rumi start')}`);
+ lines.push('');
+
+ if (opts.channel !== 'meta') {
+ lines.push(ui.aside('On your own WhatsApp number, the tap-through forms, approved templates and picture menus are unavailable — Rumi asks the same things as a normal chat instead. `rumi graduate` gets you the full experience on an official number.'));
+ lines.push('');
+ }
+
+ if (opts.channel === 'meta') {
+ lines.push(ui.bold(' Then, in Meta\'s console'));
+ lines.push(ui.aside('Point the webhook at your deployed address and subscribe it to "messages". Rumi cannot receive anything until that is done — see docs/onboarding/whatsapp.md.'));
+ lines.push('');
+ lines.push(ui.bold(' Once messages arrive, try sending'));
+ } else {
+ const target = opts.number ? `+${opts.number}` : 'your own WhatsApp number';
+ lines.push(ui.bold(` Then message ${target} from any phone and try`));
+ }
+
+ lines.push(ui.table([
+ ['Hi', ui.dim('Rumi introduces itself and asks your name')],
+ ['/menu', ui.dim('everything it can do')],
+ ['/reading test', ui.dim('assess a student reading aloud')],
+ ['a voice note', ui.dim('Rumi listens and replies')],
+ ['a photo of a worksheet', ui.dim('Rumi marks it')],
+ ], { labelRole: 'brandHi', indent: 4 }));
+ lines.push('');
+ lines.push(ui.bold(' Anytime'));
+
+ const anytime = [
+ ['rumi status', ui.dim('is Rumi running, and what is switched on')],
+ ['rumi doctor', ui.dim('check every connection')],
+ ];
+ // `rumi pair` only means something on a channel that pairs by QR — offering it
+ // on Meta would be advice that cannot be followed.
+ if (opts.channel !== 'meta') {
+ anytime.push(['rumi pair', ui.dim('re-link WhatsApp if the session drops')]);
+ anytime.push(['rumi graduate', ui.dim('move to an official WhatsApp Business number')]);
+ }
+ lines.push(ui.table(anytime, { labelRole: 'brandHi', indent: 4 }));
+ return lines.join('\n');
+}
+
+module.exports = { renderReadiness, renderNextSteps, shortFeatureName };
diff --git a/bot/scripts/setup/ui.js b/bot/scripts/setup/ui.js
new file mode 100644
index 0000000..b2bc291
--- /dev/null
+++ b/bot/scripts/setup/ui.js
@@ -0,0 +1,377 @@
+/**
+ * ui.js — the presentation layer for the `rumi` CLI.
+ *
+ * Every user-facing line printed by `rumi setup`, `rumi graduate`, `rumi pair`
+ * and `rumi status` goes through here, so the whole CLI looks like one product
+ * instead of four scripts. Nothing in this file knows anything about Rumi's
+ * domain — it is purely "how do we say things in a terminal".
+ *
+ * Two rules the rest of the CLI relies on:
+ *
+ * 1. **Colour is off unless a human is watching.** Piped output, CI logs and
+ * Jest's captured console all get plain text, so tests can assert on the
+ * words without stripping escape codes, and a redirected log stays
+ * readable.
+ * 2. **Nothing is wider than the terminal.** Paragraphs wrap and boxes size
+ * themselves to the narrower of the terminal and a comfortable reading
+ * measure, because a wrapped box border is worse than no box.
+ *
+ * @module ui
+ */
+
+// ── Colour ───────────────────────────────────────────────────────────────────
+
+/**
+ * Semantic roles, not colour names — call sites say what a line *is*
+ * (`accent` = "your turn to act") so the palette can change in one place.
+ * Each role carries a truecolor triple and a 16-colour fallback for terminals
+ * that don't advertise 24-bit support.
+ */
+const PALETTE = {
+ brandHi: { rgb: [125, 232, 205], basic: 96 },
+ brand: { rgb: [37, 211, 102], basic: 32 },
+ brandLo: { rgb: [21, 128, 61], basic: 32 },
+ accent: { rgb: [245, 176, 66], basic: 33 },
+ danger: { rgb: [239, 83, 80], basic: 31 },
+ muted: { rgb: [140, 152, 168], basic: 90 },
+ link: { rgb: [125, 211, 252], basic: 36 },
+};
+
+let colorOverride = null;
+
+/** Test hook: force colour on/off, or `null` to go back to auto-detection. */
+function setColorEnabled(value) {
+ colorOverride = value;
+}
+
+function colorEnabled() {
+ if (colorOverride !== null) return colorOverride;
+ if (process.env.NO_COLOR) return false;
+ if (process.env.FORCE_COLOR) return process.env.FORCE_COLOR !== '0';
+ return Boolean(process.stdout.isTTY);
+}
+
+function trueColorEnabled() {
+ const colorterm = (process.env.COLORTERM || '').toLowerCase();
+ return colorterm.includes('truecolor') || colorterm.includes('24bit');
+}
+
+/**
+ * @param {keyof PALETTE|null} role
+ * @param {string} text
+ * @param {{bold?: boolean, dim?: boolean}} [opts]
+ */
+function paint(role, text, opts = {}) {
+ if (!colorEnabled()) return text;
+ const codes = [];
+ if (opts.bold) codes.push('1');
+ if (opts.dim) codes.push('2');
+ const entry = role ? PALETTE[role] : null;
+ if (entry) {
+ codes.push(trueColorEnabled() ? `38;2;${entry.rgb.join(';')}` : String(entry.basic));
+ }
+ if (!codes.length) return text;
+ return `\u001b[${codes.join(';')}m${text}\u001b[0m`;
+}
+
+const bold = (t) => paint(null, t, { bold: true });
+const dim = (t) => paint('muted', t);
+const link = (t) => paint('link', t);
+
+// ── Measuring ────────────────────────────────────────────────────────────────
+
+const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
+
+const stripAnsi = (text) => String(text).replace(ANSI_PATTERN, '');
+
+/**
+ * Printed width of a string in terminal cells: escape codes are free, CJK and
+ * emoji take two cells, and combining marks take none. Box borders are drawn
+ * from this, so getting it wrong shows up immediately as a ragged edge.
+ *
+ * @param {string} text
+ * @returns {number}
+ */
+function visibleWidth(text) {
+ let width = 0;
+ for (const char of stripAnsi(text)) {
+ const cp = char.codePointAt(0);
+ // Zero-width: variation selectors, ZWJ, skin-tone modifiers, combining marks.
+ if (cp === 0xfe0f || cp === 0xfe0e || cp === 0x200d
+ || (cp >= 0x1f3fb && cp <= 0x1f3ff) || (cp >= 0x0300 && cp <= 0x036f)) continue;
+ const isWide = (cp >= 0x1100 && cp <= 0x115f)
+ || (cp >= 0x2e80 && cp <= 0xa4cf)
+ || (cp >= 0xac00 && cp <= 0xd7a3)
+ || (cp >= 0xf900 && cp <= 0xfaff)
+ || (cp >= 0xfe30 && cp <= 0xfe6f)
+ || (cp >= 0xff00 && cp <= 0xff60)
+ || (cp >= 0x1f300 && cp <= 0x1f64f)
+ || (cp >= 0x1f680 && cp <= 0x1f6ff)
+ || (cp >= 0x1f900 && cp <= 0x1f9ff)
+ || cp === 0x2705 || cp === 0x274c || cp === 0x2728;
+ width += isWide ? 2 : 1;
+ }
+ return width;
+}
+
+/** Comfortable reading measure, never wider than the window. */
+const MAX_MEASURE = 74;
+function measure() {
+ const columns = process.stdout.columns || 80;
+ return Math.max(40, Math.min(MAX_MEASURE, columns - 2));
+}
+
+/**
+ * Word-wrap to `width` cells, preserving explicit newlines as paragraph breaks.
+ * Words longer than the width (a pasted URL) are left intact rather than
+ * broken — a split URL can't be clicked or copied.
+ *
+ * @param {string} text
+ * @param {number} [width]
+ * @returns {string[]}
+ */
+function wrap(text, width = measure()) {
+ const out = [];
+ for (const paragraph of String(text).split('\n')) {
+ let line = '';
+ for (const word of paragraph.split(/ +/)) {
+ if (!line) { line = word; continue; }
+ if (visibleWidth(`${line} ${word}`) <= width) line += ` ${word}`;
+ else { out.push(line); line = word; }
+ }
+ out.push(line);
+ }
+ return out;
+}
+
+// ── Blocks ───────────────────────────────────────────────────────────────────
+
+const LOGO_LINES = [
+ ['██████╗ ██╗ ██╗███╗ ███╗██╗', 'brandHi'],
+ ['██╔══██╗██║ ██║████╗ ████║██║', 'brandHi'],
+ ['██████╔╝██║ ██║██╔████╔██║██║', 'brand'],
+ ['██╔══██╗██║ ██║██║╚██╔╝██║██║', 'brand'],
+ ['██║ ██║╚██████╔╝██║ ╚═╝ ██║██║', 'brandLo'],
+ ['╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝', 'brandLo'],
+];
+
+/**
+ * The wordmark, plus an optional tagline underneath. Falls back to a plain
+ * one-liner on a narrow terminal (phones over SSH, split panes) where the
+ * block letters would wrap into noise.
+ *
+ * @param {string} [tagline]
+ * @returns {string}
+ */
+function logo(tagline = '') {
+ const columns = process.stdout.columns || 80;
+ const lines = columns < 34
+ ? [paint('brand', 'RUMI', { bold: true })]
+ : LOGO_LINES.map(([text, role]) => paint(role, text));
+ if (tagline) lines.push('', dim(tagline));
+ return `\n${lines.join('\n')}\n`;
+}
+
+const BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
+
+/**
+ * A rounded box around already-wrapped lines.
+ *
+ * Deliberately does no wrapping of its own: the caller decides what a line is,
+ * because the two things that go in boxes — SQL to copy and short checklists —
+ * both break badly under automatic wrapping.
+ *
+ * @param {string[]} lines
+ * @param {{title?: string, role?: keyof PALETTE}} [opts]
+ * @returns {string}
+ */
+function box(lines, opts = {}) {
+ const role = opts.role || 'muted';
+ // The frame matches the widest line rather than being clipped to the window:
+ // the only things boxed here are meant to be copied (SQL, a checklist), and a
+ // truncated line that looks complete is worse than one the terminal wraps.
+ // Callers keep lines inside `measure() - 4`; this stays self-consistent if
+ // one does not.
+ const width = Math.max(
+ visibleWidth(opts.title || '') + 2,
+ ...lines.map((l) => visibleWidth(l)),
+ );
+ const edge = (left, right, label = '') => {
+ const labelPart = label ? ` ${label} ` : '';
+ const fill = BOX.h.repeat(Math.max(0, width + 2 - visibleWidth(labelPart)));
+ return paint(role, `${left}${labelPart}${fill}${right}`);
+ };
+ const body = lines.map((line) => {
+ const pad = ' '.repeat(Math.max(0, width - visibleWidth(line)));
+ return `${paint(role, BOX.v)} ${line}${pad} ${paint(role, BOX.v)}`;
+ });
+ return [edge(BOX.tl, BOX.tr, opts.title), ...body, edge(BOX.bl, BOX.br)].join('\n');
+}
+
+/**
+ * A numbered step header with a progress bar — the answer to "how much
+ * further?", which is the question an unattended wizard most often leaves
+ * unanswered.
+ *
+ * @param {number} index 1-based
+ * @param {number} total
+ * @param {string} title
+ * @returns {string}
+ */
+function progressBar(index, total) {
+ // Filled proportional to the step being *entered*, so the very first header
+ // shows movement rather than an apparently broken empty bar.
+ const filled = Math.round((index / total) * 12);
+ const bar = `${'━'.repeat(filled)}${'┄'.repeat(12 - filled)}`;
+ return `${paint('brand', bar)} ${dim(`step ${index} of ${total}`)}`;
+}
+
+function step(index, total, title) {
+ return ['', progressBar(index, total), bold(title)].join('\n');
+}
+
+/**
+ * Starts a step at the top of the screen.
+ *
+ * Without this, every prompt lands on the last line of the terminal with its
+ * explanation scrolled above it — the thing you have to read and the thing you
+ * have to type end up at opposite ends of the window, and by step five you are
+ * typing into the bottom edge. Clearing per step keeps each question in the top
+ * third, where it can be read and answered in one place.
+ *
+ * The scrollback buffer is cleared too (\u001b[3J): leaving it means the wizard
+ * appears to have "jumped" when the user scrolls up mid-step. Progress is not
+ * lost from view — the caller reprints a tick for each completed step.
+ */
+function clearScreen() {
+ if (!process.stdout.isTTY) return;
+ process.stdout.write('\u001b[2J\u001b[3J\u001b[H');
+}
+
+/** A full-width rule — closes a section without shouting. */
+const rule = () => dim('─'.repeat(measure()));
+
+// ── Lines ────────────────────────────────────────────────────────────────────
+
+const ok = (text) => `${paint('brand', '✔')} ${text}`;
+const fail = (text) => `${paint('danger', '✘')} ${text}`;
+const warn = (text) => `${paint('accent', '!')} ${text}`;
+const arrow = (text) => `${paint('accent', '›')} ${text}`;
+
+/**
+ * A bulleted line that wraps with a hanging indent, so a long point stays a
+ * single visual item instead of its tail drifting back to the margin.
+ *
+ * @param {string} text
+ * @param {{dim?: boolean}} [opts] secondary bullets (caveats, "you can skip this")
+ */
+function bullet(text, opts = {}) {
+ const style = opts.dim ? dim : (t) => t;
+ const [first, ...rest] = wrap(text, measure() - 4);
+ return [` ${dim('•')} ${style(first)}`, ...rest.map((l) => ` ${style(l)}`)].join('\n');
+}
+
+/** An indented explanatory paragraph — the "why am I being asked this" copy. */
+function say(text) {
+ return wrap(text, measure() - 2).map((l) => ` ${l}`).join('\n');
+}
+
+/** Same, but visibly secondary (hints, caveats, "you can skip this"). */
+function aside(text) {
+ return wrap(text, measure() - 2).map((l) => ` ${dim(l)}`).join('\n');
+}
+
+/**
+ * A "do this in your browser" list. Numbered, indented, with any URL coloured
+ * so it stands out as the thing to click.
+ *
+ * @param {string[]} items
+ * @returns {string}
+ */
+function steps(items) {
+ return items.map((item, i) => {
+ // Trailing punctuation is sentence, not URL — without excluding it, a link
+ // followed by a comma gets the comma coloured as part of itself, which
+ // reads as though the comma belongs in the address.
+ const highlighted = item.replace(/https?:\/\/[^\s)]*[^\s).,;:]/g, (m) => link(m));
+ const [first, ...rest] = wrap(highlighted, measure() - 6);
+ return [` ${paint('accent', `${i + 1}.`)} ${first}`, ...rest.map((l) => ` ${l}`)].join('\n');
+ }).join('\n');
+}
+
+/**
+ * Aligned `label value` rows.
+ *
+ * The label is dim by default, because most tables here are readouts where the
+ * *value* is the news ("Database ready"). Pass a `labelRole` for the inverted
+ * case — a command list, where the label is the thing you came to find.
+ *
+ * @param {Array<[string, string]>} rows
+ * @param {{labelRole?: keyof PALETTE, indent?: number}} [opts]
+ * @returns {string}
+ */
+function table(rows, opts = {}) {
+ const pad = ' '.repeat(opts.indent === undefined ? 2 : opts.indent);
+ const paintLabel = opts.labelRole ? (t) => paint(opts.labelRole, t) : dim;
+ const labelWidth = Math.max(...rows.map(([label]) => visibleWidth(label)));
+ return rows.map(([label, value]) => {
+ const gap = ' '.repeat(labelWidth - visibleWidth(label));
+ return `${pad}${paintLabel(label)}${gap} ${value}`;
+ }).join('\n');
+}
+
+// ── Progress ─────────────────────────────────────────────────────────────────
+
+const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
+
+/**
+ * An in-place "checking…" indicator that resolves to a single ✔/✘ line.
+ *
+ * Non-TTY output gets the label once and the result once, with no cursor
+ * tricks — the same information, still readable in a log file. Always stop it
+ * in a `finally`: a spinner left running holds the event loop open and the
+ * command never exits.
+ *
+ * @param {string} label
+ */
+function spinner(label) {
+ const live = colorEnabled() && process.stdout.isTTY;
+ let timer = null;
+ let frame = 0;
+
+ if (live) {
+ const render = () => {
+ process.stdout.write(`\r${paint('accent', SPINNER_FRAMES[frame % SPINNER_FRAMES.length])} ${label} `);
+ frame += 1;
+ };
+ render();
+ timer = setInterval(render, 90);
+ if (typeof timer.unref === 'function') timer.unref();
+ } else {
+ console.log(` ${label}`);
+ }
+
+ const finish = (line) => {
+ if (timer) clearInterval(timer);
+ if (live) process.stdout.write(`\r\u001b[2K`);
+ console.log(line);
+ };
+
+ return {
+ succeed: (text) => finish(ok(text || label)),
+ fail: (text) => finish(fail(text || label)),
+ warn: (text) => finish(warn(text || label)),
+ stop: () => {
+ if (timer) clearInterval(timer);
+ if (live) process.stdout.write(`\r\u001b[2K`);
+ },
+ };
+}
+
+module.exports = {
+ paint, bold, dim, link, setColorEnabled, colorEnabled,
+ stripAnsi, visibleWidth, wrap, measure,
+ logo, box, step, progressBar, rule, clearScreen,
+ ok, fail, warn, arrow, bullet, say, aside, steps, table,
+ spinner,
+};
diff --git a/bot/scripts/setup/validators.js b/bot/scripts/setup/validators.js
new file mode 100644
index 0000000..168abe7
--- /dev/null
+++ b/bot/scripts/setup/validators.js
@@ -0,0 +1,223 @@
+/**
+ * validators.js — field-shape checks for every value `rumi setup` and
+ * `rumi graduate` collect.
+ *
+ * These exist because the expensive setup failures are not "I typed it wrong",
+ * they are "I pasted the wrong *thing*" — a value that is perfectly well-formed
+ * for what it actually is, so nothing complains until a feature dies hours
+ * later with a 401 and no hint about which of eight keys is at fault. The
+ * classics, all caught here:
+ *
+ * - Supabase's **anon** key instead of the **service_role** key. Both are
+ * JWTs beginning `eyJ`, indistinguishable by eye — but the anon key cannot
+ * see past row-level security, so the bot starts fine and then behaves as
+ * if the database were empty. Decoding the token's `role` claim settles it
+ * in the wizard instead of in production.
+ * - A phone *number* in `PHONE_NUMBER_ID`, which wants Meta's internal id.
+ * Graph answers "Object with ID does not exist", naming neither field.
+ * - Any other vendor's key in `OPENROUTER_API_KEY` — every AI provider hands
+ * out an `sk-…`, and they all look alike in a terminal.
+ *
+ * Each validator returns `{ ok, reason?, value? }`. Returning a `value` lets a
+ * validator clean input (strip a trailing slash, quotes, a `psql` prefix)
+ * rather than making the user paste tidily.
+ *
+ * @module validators
+ */
+
+/** @typedef {{ok: boolean, reason?: string, value?: string}} Verdict */
+
+const ok = (value) => ({ ok: true, value });
+const no = (reason) => ({ ok: false, reason });
+
+/** Strips wrapping quotes and stray whitespace — the usual copy-paste debris. */
+function clean(input) {
+ return String(input || '').trim().replace(/^['"]|['"]$/g, '').trim();
+}
+
+/** Requires a non-empty value, for fields with no other shape to check. */
+function required(label) {
+ return (input) => {
+ const value = clean(input);
+ return value ? ok(value) : no(`${label} can't be empty.`);
+ };
+}
+
+/** Recognises the well-known key prefixes, so we can say *what* was pasted. */
+const FOREIGN_KEY_PREFIXES = [
+ [/^sk-ant-/, 'an Anthropic API key'],
+ [/^sk-proj-/, 'an OpenAI project key'],
+ [/^sk-svcacct-/, 'an OpenAI service-account key'],
+ [/^AIza/, 'a Google API key'],
+ [/^xox[bpa]-/, 'a Slack token'],
+ [/^gh[pousr]_/, 'a GitHub token'],
+ [/^EAA/, 'a Meta/WhatsApp access token'],
+ [/^eyJ/, 'a JWT — probably a Supabase key'],
+];
+
+function identifyForeignKey(value) {
+ for (const [pattern, description] of FOREIGN_KEY_PREFIXES) {
+ if (pattern.test(value)) return description;
+ }
+ return null;
+}
+
+// ── Supabase ─────────────────────────────────────────────────────────────────
+
+/** @returns {Verdict} */
+function supabaseUrl(input) {
+ const value = clean(input).replace(/\/+$/, '');
+ if (!value) return no("The project URL can't be empty.");
+ if (/^eyJ|^sb_/.test(value)) return no('That looks like a key, not a URL. The project URL looks like https://abcdefgh.supabase.co');
+ if (!/^https?:\/\//.test(value)) {
+ return /\.supabase\.(co|in)$/.test(value)
+ ? ok(`https://${value}`)
+ : no('That should start with https:// — copy the "Project URL" field exactly.');
+ }
+ if (/supabase\.com\/dashboard/.test(value)) {
+ return no('That is the dashboard page in your browser, not the API URL. The one you want is under Project Settings → Data API, and ends in .supabase.co');
+ }
+ if (!/\.supabase\.(co|in)$/.test(value) && !/localhost|127\.0\.0\.1/.test(value)) {
+ return no('That does not look like a Supabase project URL (expected something ending in .supabase.co).');
+ }
+ return ok(value);
+}
+
+/**
+ * A Supabase JWT carries its role in the payload. Returns the role string, or
+ * null when the token isn't a decodable JWT (the newer `sb_secret_…` keys
+ * aren't, and that's fine — they're unambiguous by prefix).
+ */
+function jwtRole(token) {
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
+ return typeof payload.role === 'string' ? payload.role : null;
+ } catch {
+ return null;
+ }
+}
+
+/** @returns {Verdict} */
+function supabaseServiceKey(input) {
+ const value = clean(input);
+ if (!value) return no("The service key can't be empty.");
+ if (/^https?:\/\//.test(value)) return no('That is the project URL, not the key.');
+ if (value.startsWith('sb_publishable_')) {
+ return no('That is the publishable key, which cannot read your data. You need the secret key (sb_secret_…) from the same page.');
+ }
+ if (value.startsWith('sb_secret_')) return ok(value);
+
+ const role = jwtRole(value);
+ if (role === 'anon') {
+ return no('That is the anon (public) key. Rumi needs the service_role key — same page, but you have to click "Reveal" to see it. The anon key cannot read your tables, so the bot would run but find no data.');
+ }
+ if (role === 'service_role') return ok(value);
+ if (value.startsWith('eyJ')) return ok(value); // a JWT we couldn't decode — let the live check judge it
+ return no('That does not look like a Supabase key (expected one starting with eyJ or sb_secret_).');
+}
+
+// ── OpenRouter ───────────────────────────────────────────────────────────────
+
+/** @returns {Verdict} */
+function openrouterKey(input) {
+ const value = clean(input);
+ if (!value) return no("The key can't be empty.");
+ if (value.startsWith('sk-or-')) {
+ return value.length < 20 ? no('That key looks truncated — copy the whole thing.') : ok(value);
+ }
+ const foreign = identifyForeignKey(value);
+ if (foreign) {
+ return no(`That is ${foreign}, not an OpenRouter key. OpenRouter keys start with "sk-or-" and come from openrouter.ai/keys.`);
+ }
+ return no('OpenRouter keys start with "sk-or-". Create one at openrouter.ai/keys.');
+}
+
+// ── Redis ────────────────────────────────────────────────────────────────────
+
+/** @returns {Verdict} */
+function redisUrl(input) {
+ const value = clean(input);
+ if (!value) return no("The Redis address can't be empty.");
+ if (/^rediss?:\/\//.test(value)) return ok(value);
+ // A bare host:port is what most dashboards show. Wrapping it is what the
+ // user meant, and guessing wrong here is harmless — the live check follows.
+ if (/^[\w.-]+:\d+$/.test(value)) return ok(`redis://${value}`);
+ if (/^https?:\/\//.test(value)) {
+ return no('Redis addresses start with redis:// (or rediss:// for TLS), not http://. Upstash shows both — copy the one labelled "Redis" or "TCP".');
+ }
+ return no('That should look like redis://host:6379 — or redis://default:password@host:6379 for a hosted one.');
+}
+
+// ── Meta / WhatsApp Cloud API ────────────────────────────────────────────────
+
+/** @returns {Verdict} */
+function whatsappToken(input) {
+ const value = clean(input);
+ if (!value) return no("The access token can't be empty.");
+ if (!value.startsWith('EAA')) {
+ const foreign = identifyForeignKey(value);
+ return no(foreign
+ ? `That is ${foreign}, not a Meta access token. Meta's start with "EAA".`
+ : 'Meta access tokens start with "EAA" — copy it from API Setup → "Generate access token".');
+ }
+ if (value.length < 100) return no(`That token looks truncated (${value.length} characters; they are usually 200+). Copy the whole thing.`);
+ return ok(value);
+}
+
+/** @returns {Verdict} */
+function phoneNumberId(input) {
+ const value = clean(input).replace(/[\s-]/g, '');
+ if (!value) return no("The phone number ID can't be empty.");
+ if (value.startsWith('+') || /[^\d]/.test(value)) {
+ return no('This field wants digits only — and not the phone number itself. Look for the line labelled "Phone number ID" directly under the "From" dropdown in API Setup.');
+ }
+ if (value.length <= 12) {
+ return no(`That looks like the phone number (${value.length} digits). Meta's phone number ID is a separate 15-17 digit value shown right below the "From" dropdown — the phone number itself is never used in configuration.`);
+ }
+ if (value.length > 20) return no('That is longer than any phone number ID (expected 15-17 digits) — check you copied only the ID.');
+ return ok(value);
+}
+
+/** @returns {Verdict} */
+function wabaId(input) {
+ const value = clean(input).replace(/[\s-]/g, '');
+ if (!value) return no("The account ID can't be empty.");
+ if (/[^\d]/.test(value)) return no('The WhatsApp Business Account ID is digits only — find it in API Setup, or in Business Settings → WhatsApp Accounts.');
+ if (value.length < 10) return no('That looks too short for a WhatsApp Business Account ID (expected 15-16 digits).');
+ return ok(value);
+}
+
+/** @returns {Verdict} */
+function webhookVerifyToken(input) {
+ const value = clean(input);
+ if (!value) return no("This can't be empty — it's a password you invent, and you'll paste the same one into Meta's webhook form.");
+ if (/\s/.test(value)) return no('No spaces — Meta sends this back verbatim in a URL, so spaces break the comparison.');
+ if (value.length < 8) return no('Make it at least 8 characters. Anyone who guesses it can register a fake webhook.');
+ return ok(value);
+}
+
+/** Per-env-var validator lookup, so `graduate` and `setup` can't disagree. */
+const BY_ENV_VAR = {
+ SUPABASE_URL: supabaseUrl,
+ SUPABASE_SERVICE_ROLE_KEY: supabaseServiceKey,
+ OPENROUTER_API_KEY: openrouterKey,
+ REDIS_URL: redisUrl,
+ WHATSAPP_TOKEN: whatsappToken,
+ PHONE_NUMBER_ID: phoneNumberId,
+ WABA_ID: wabaId,
+ WEBHOOK_VERIFY_TOKEN: webhookVerifyToken,
+};
+
+/** @returns {(input: string) => Verdict} a validator for `envVar`, or a presence check. */
+function validatorFor(envVar) {
+ return BY_ENV_VAR[envVar] || required(envVar);
+}
+
+module.exports = {
+ clean, required, jwtRole, identifyForeignKey,
+ supabaseUrl, supabaseServiceKey, openrouterKey, redisUrl,
+ whatsappToken, phoneNumberId, wabaId, webhookVerifyToken,
+ validatorFor, BY_ENV_VAR,
+};
diff --git a/bot/shared/config/feature-availability.js b/bot/shared/config/feature-availability.js
index f0676dc..bd47246 100644
--- a/bot/shared/config/feature-availability.js
+++ b/bot/shared/config/feature-availability.js
@@ -9,20 +9,33 @@
*
* Each feature's `keys` list is verified against the code that actually reads
* them, so `doctor` and any runtime gate report the truth, not an aspiration.
+ *
+ * The messaging channel is presence-gated the same way, just scoped by
+ * CHANNEL_DRIVER (see resolveChannelDriver below and
+ * bot/shared/services/messaging/channel-registry.js): the WhatsApp/Meta vars
+ * are required only when the resolved channel is `meta` — a sandbox
+ * (Baileys) deployment needs none of them. This is a second presence-based
+ * selector, not a new tier system, the same shape as QUEUE_DRIVER.
*/
-// Hard requirements: the bot will not start without all of these.
+const { DRIVERS, DEFAULT_DRIVER } = require('../services/messaging/channel-registry');
+
+// Hard requirements, independent of messaging channel: the bot will not start
+// without all of these.
const REQUIRED_VARS = [
'SUPABASE_URL',
'SUPABASE_SERVICE_ROLE_KEY',
'OPENROUTER_API_KEY',
'REDIS_URL',
- 'WHATSAPP_TOKEN',
- 'PHONE_NUMBER_ID',
- 'WEBHOOK_VERIFY_TOKEN',
- 'WABA_ID',
];
+// Additional vars required per messaging channel driver. Adding a new channel
+// (e.g. Slack) is a one-line addition here — no restructuring.
+const CHANNEL_REQUIRED_VARS = {
+ meta: ['WHATSAPP_TOKEN', 'PHONE_NUMBER_ID', 'WEBHOOK_VERIFY_TOKEN', 'WABA_ID'],
+ baileys: [],
+};
+
// Optional features → the env key(s) that switch each one on.
const FEATURES = [
{ name: 'Voice notes (speech-to-text, Soniox)', keys: ['SONIOX_API_KEY'] },
@@ -55,9 +68,35 @@ const FEATURES = [
const PLACEHOLDER_RE = /^CHANGEME|your-project|your_|^YOUR_|^<.*>$/i;
const isSet = (v) => typeof v === 'string' && v.trim() !== '' && !PLACEHOLDER_RE.test(v.trim());
-/** Required vars that are NOT set (empty array = ready to boot). */
+/**
+ * Which messaging channel driver applies for this env. Explicit CHANNEL_DRIVER
+ * wins when it names a known driver; an unknown explicit value falls back to
+ * DEFAULT_DRIVER (messaging/index.js logs that case — this function stays a
+ * pure, side-effect-free config read). With no CHANNEL_DRIVER set at all,
+ * infer `meta` if ANY of its required vars is already present — a
+ * pre-existing or partially-configured Meta deployment must keep being told
+ * what's missing, not get silently reclassified as sandbox with nothing
+ * required.
+ */
+function resolveChannelDriver(env = process.env) {
+ const explicit = (env.CHANNEL_DRIVER || '').trim().toLowerCase();
+ if (explicit) {
+ return Object.prototype.hasOwnProperty.call(DRIVERS, explicit) ? explicit : DEFAULT_DRIVER;
+ }
+ const metaVars = CHANNEL_REQUIRED_VARS.meta;
+ if (metaVars.some((k) => isSet(env[k]))) return 'meta';
+ return DEFAULT_DRIVER;
+}
+
+/** The full required-vars list for this env: the channel-independent core plus whichever channel is resolved. */
+function requiredVarsFor(env = process.env) {
+ const channel = resolveChannelDriver(env);
+ return [...REQUIRED_VARS, ...(CHANNEL_REQUIRED_VARS[channel] || [])];
+}
+
+/** Required vars (core + resolved channel) that are NOT set (empty array = ready to boot). */
function missingRequired(env = process.env) {
- return REQUIRED_VARS.filter((k) => !isSet(env[k]));
+ return requiredVarsFor(env).filter((k) => !isSet(env[k]));
}
/**
@@ -82,4 +121,14 @@ function availableFeatures(env = process.env) {
return FEATURES.filter((f) => isFeatureAvailable(f, env)).map((f) => f.name);
}
-module.exports = { REQUIRED_VARS, FEATURES, isSet, missingRequired, isFeatureAvailable, availableFeatures };
+module.exports = {
+ REQUIRED_VARS,
+ CHANNEL_REQUIRED_VARS,
+ FEATURES,
+ isSet,
+ resolveChannelDriver,
+ requiredVarsFor,
+ missingRequired,
+ isFeatureAvailable,
+ availableFeatures,
+};
diff --git a/bot/shared/constants/feature-videos.js b/bot/shared/constants/feature-videos.js
index 1ff128c..e13ff18 100644
--- a/bot/shared/constants/feature-videos.js
+++ b/bot/shared/constants/feature-videos.js
@@ -8,12 +8,18 @@
* 3. When keywords are detected in chat (explicit consent via buttons)
*/
+// Presence-gated, like every other optional asset. With R2_PUBLIC_URL unset
+// these interpolated to a RELATIVE path ("/feature_videos/reading_intro.mp4"),
+// which is not fetchable by anything — so the bot asked "Want to see how? 🎥",
+// the teacher said yes, and nothing ever arrived (the send failed with "Could
+// not extract R2 key from URL"). Null means "there is no video here", and callers
+// then don't make the offer at all.
const R2_BASE = process.env.R2_PUBLIC_URL || '';
const FEATURE_VIDEO_URLS = {
- lesson_plan: `${R2_BASE}/feature_videos/lesson_plan_intro.mp4`,
- coaching: `${R2_BASE}/feature_videos/coaching_intro.mp4`,
- reading: `${R2_BASE}/feature_videos/reading_intro.mp4`,
+ lesson_plan: R2_BASE ? `${R2_BASE}/feature_videos/lesson_plan_intro.mp4` : null,
+ coaching: R2_BASE ? `${R2_BASE}/feature_videos/coaching_intro.mp4` : null,
+ reading: R2_BASE ? `${R2_BASE}/feature_videos/reading_intro.mp4` : null,
};
/**
diff --git a/bot/shared/handlers/image-message.handler.js b/bot/shared/handlers/image-message.handler.js
index c06855d..26cff30 100644
--- a/bot/shared/handlers/image-message.handler.js
+++ b/bot/shared/handlers/image-message.handler.js
@@ -296,17 +296,29 @@ async function handleImageMessage(message, from, user = null) {
* localized generic error message (gated by idempotencyAcquired). Returns
* `{ idempotencyAcquired }` so the caller can update its own flag for that guard.
*/
-async function runImageAnalysis({ user, from, imageId, mimeType, caption, typingController, correlationId, startTime }) {
+async function runImageAnalysis({
+ user, from, imageId, mimeType, caption, typingController, correlationId, startTime,
+ // The idempotency claim below exists to stop a REDELIVERY of the same image
+ // being analysed twice. The pic-LP batch coalescer is not a redelivery: the
+ // router already tagged this key ('pic_lp_handled') on its behalf before
+ // handing the image to the batch, so when the batch flushes and asks for
+ // vision feedback the key is always already taken — and the teacher got NO
+ // reply at all for any non-textbook image. It passes false to say "this is the
+ // continuation of that same handling, not a second copy of it".
+ claimIdempotency = true,
+}) {
// Get or create session for conversation history
const sessionId = await getOrCreateSession(user.id);
// Atomic idempotency check — SET NX ensures only one handler proceeds
const idempotencyKey = `image:${user.id}:${imageId}`;
- const idempotencyAcquired = await redisService.setNX(
- idempotencyKey,
- JSON.stringify({ status: 'processing', startedAt: Date.now() }),
- IDEMPOTENCY_TTL_SECONDS
- );
+ const idempotencyAcquired = claimIdempotency
+ ? await redisService.setNX(
+ idempotencyKey,
+ JSON.stringify({ status: 'processing', startedAt: Date.now() }),
+ IDEMPOTENCY_TTL_SECONDS
+ )
+ : true; // already claimed upstream on this call's behalf — see the parameter
if (!idempotencyAcquired) {
// Another handler already has this image — check for cached result
@@ -688,6 +700,8 @@ async function handleCoalescedBatch({ user, from, batch }) {
typingController: primaryTypingController,
correlationId,
startTime,
+ // Not a redelivery — the router already claimed this key before batching.
+ claimIdempotency: false,
});
} catch (visionErr) {
logToFile('⚠️ runImageAnalysis from coalescer threw', {
diff --git a/bot/shared/handlers/portal-command.handler.js b/bot/shared/handlers/portal-command.handler.js
index 073dfba..2dd9247 100644
--- a/bot/shared/handlers/portal-command.handler.js
+++ b/bot/shared/handlers/portal-command.handler.js
@@ -53,6 +53,17 @@ async function handlePortalCommand(user, phoneNumber) {
es: 'Tu cuenta ya está activa. Pide la URL del portal a tu administrador.'
},
+ // PORTAL_URL is not set on this deployment. A permanent configuration
+ // gap, not a hiccup — the generic `error` message below tells the teacher
+ // to "try again in a few minutes", which will never help and reads as a
+ // bug in the bot rather than a feature this deployment hasn't set up.
+ notConfigured: {
+ en: "The teacher portal isn't set up on this deployment yet, so there's no link I can send you. Everything else still works here in chat — type /menu to see what I can do.",
+ ur: 'اس ڈیپلائمنٹ پر ٹیچر پورٹل ابھی سیٹ اپ نہیں ہے، اس لیے میں آپ کو لنک نہیں بھیج سکتا۔ باقی سب کچھ چیٹ میں کام کرتا ہے — /menu ٹائپ کریں۔',
+ ar: 'لم يتم إعداد بوابة المعلم في هذا النظام بعد، لذا لا يوجد رابط لإرساله. كل شيء آخر يعمل هنا في المحادثة — اكتب /menu.',
+ es: 'El portal del docente aún no está configurado en esta instalación, así que no hay enlace que enviarte. Todo lo demás funciona aquí en el chat: escribe /menu.',
+ },
+
// Error sending invitation
error: {
en: `Sorry, I couldn't create your portal invitation right now. Please try again in a few minutes or contact support.\n\nمعذرت، میں ابھی آپ کا پورٹل دعوت نامہ نہیں بنا سکا۔ براہ کرم کچھ منٹوں میں دوبارہ کوشش کریں۔`,
@@ -71,6 +82,14 @@ async function handlePortalCommand(user, phoneNumber) {
return messages.alreadyActivated[language] || messages.alreadyActivated.en;
}
+ // Nothing to invite anyone to without a portal URL. Checked BEFORE the
+ // service call, which would otherwise mint and store an invite token that
+ // can never be used.
+ if (!portalBase) {
+ logToFile('⚠️ /portal requested but PORTAL_URL is not configured', { userId: user.id });
+ return messages.notConfigured[language] || messages.notConfigured.en;
+ }
+
// Send portal invitation (generates token, stores in DB, sends WhatsApp message)
const result = await PortalInviteService.sendPortalInvite(
user.id,
diff --git a/bot/shared/handlers/text-message.handler.js b/bot/shared/handlers/text-message.handler.js
index fa7d6c7..a7940b3 100644
--- a/bot/shared/handlers/text-message.handler.js
+++ b/bot/shared/handlers/text-message.handler.js
@@ -114,7 +114,11 @@ async function handleTextMessage(message, from, messageBody, user = null) {
}
const quizState = await QuizSessionService.getActiveState(from);
- if (quizState) {
+ // A slash command is never a quiz answer. Without this, ANY user with a
+ // live quiz session could not run a single command — every /menu, /video
+ // or /quiz came back as "Tap one of the answer buttons above". A teacher
+ // is often also a parent on the same number, so this is not a corner case.
+ if (quizState && !messageBody.trim().startsWith('/')) {
const trimmedQ = messageBody.trim();
const lowerQ = trimmedQ.toLowerCase();
if (/^(start quiz|start_quiz|کوئز شروع کریں)$/i.test(trimmedQ)) {
@@ -123,10 +127,16 @@ async function handleTextMessage(message, from, messageBody, user = null) {
await QuizSessionService.endSession(from, quizState, 'incomplete');
} else if (/^[abc]$/i.test(trimmedQ) && quizState.currentQuestionId) {
await QuizSessionService.handleAnswer(from, trimmedQ, quizState);
- } else {
+ } else if (quizState.currentQuestionId) {
await WhatsAppService.sendMessage(from,
'❓ Tap one of the answer buttons above, or type A, B, or C.\n\nType STOP to exit the quiz.'
);
+ } else {
+ // Invited but not started: there is no question "above" to answer,
+ // so pointing at answer buttons is simply wrong.
+ await WhatsAppService.sendMessage(from,
+ '❓ Reply *Start Quiz* when you are ready to begin.\n\nType STOP if you would rather not.'
+ );
}
typingController.stop();
return;
@@ -436,9 +446,13 @@ async function handleTextMessage(message, from, messageBody, user = null) {
logToFile('📹 First-use intro video sent for reading assessment', { userId: user.id });
}
- // Send WhatsApp Flow for reading assessment setup
+ // Send WhatsApp Flow for reading assessment setup. On a channel with no
+ // Flow support this becomes the equivalent text conversation (same
+ // fields, same submission shape) — see messaging/text-flow-definitions.js.
const flowSent = await WhatsAppService.sendFlow(from, {
flowId: process.env.READING_ASSESSMENT_FLOW_ID,
+ flowKind: 'reading-assessment',
+ flowToken: `${user.id}:reading-assessment:${Date.now()}`,
header: '📚 Reading Assessment',
body: 'Let\'s set up a reading assessment for your student. This will help measure their reading fluency and comprehension.',
footer: 'Takes about 5-10 minutes',
@@ -451,7 +465,14 @@ async function handleTextMessage(message, from, messageBody, user = null) {
// Mark feature as used (after video was shown)
await FeatureIntroService.markFeatureUsed(user.id, 'reading');
} else {
- throw new Error('Failed to send WhatsApp Flow');
+ // Not an exception: this channel simply cannot offer the assessment.
+ // Throwing here used to produce "Sorry, something went wrong", which
+ // reads as a bug rather than as a feature that isn't configured.
+ logToFile('⚠️ Reading assessment unavailable on this channel', { userId: user.id });
+ await WhatsAppService.sendMessage(from, ({
+ ur: 'ریڈنگ اسسمنٹ ابھی سیٹ اپ نہیں ہے۔ /menu ٹائپ کریں یہ دیکھنے کے لیے کہ میں اور کیا کر سکتا ہوں۔',
+ })[await getUserLanguage(from) || 'en']
+ || 'The reading assessment is not set up on this deployment yet. Type /menu to see what else I can do.');
}
} catch (error) {
logToFile('❌ Error sending reading assessment flow', {
@@ -553,25 +574,33 @@ async function handleTextMessage(message, from, messageBody, user = null) {
return;
}
- // Presence-gated: when STUDENT_VIDEOS_FLOW_ID is set, /video opens the
- // pre-made Student Video Library picker. When it is empty, /video falls
- // through to the runtime video generator below.
+ // The pre-made Student Video Library picker. Attempted whenever a picker
+ // can be rendered AT ALL — either as a Meta Flow (STUDENT_VIDEOS_FLOW_ID
+ // set) or, on a channel with no Flow support, as the equivalent text
+ // conversation. Only if neither is possible does /video fall through to the
+ // runtime video generator below.
+ //
+ // Previously this was gated on STUDENT_VIDEOS_FLOW_ID alone, which made the
+ // whole imported library unreachable on the sandbox driver: the ID can only
+ // exist once a Meta Flow has been published, so /video always skipped the
+ // library and went to the generator, which then needs its own API keys.
const STUDENT_VIDEOS_FLOW_ID = process.env.STUDENT_VIDEOS_FLOW_ID || '';
- if (STUDENT_VIDEOS_FLOW_ID) {
- typingController.stop();
- const flowToken = `${user?.id || 'anon'}:student-videos:${Date.now()}`;
- await WhatsAppService.sendFlow(from, {
- flowId: STUDENT_VIDEOS_FLOW_ID,
- header: '🎬 Student Videos',
- body: ({
- ur: 'اپنی کلاس، مضمون اور موضوع چنیں — میں ویڈیو آپ کی چیٹ میں بھیج دوں گا۔',
- })[responseLanguage] || 'Pick a class, subject and topic — I will send the video to your chat.',
- buttonText: ({
- ur: 'تلاش کریں',
- })[responseLanguage] || 'Browse',
- flowToken,
- });
- logToFile('🎬 Sent student videos flow (/video)', { userId: user?.id });
+ typingController.stop();
+ const videoFlowToken = `${user?.id || 'anon'}:student-videos:${Date.now()}`;
+ const pickerSent = await WhatsAppService.sendFlow(from, {
+ flowId: STUDENT_VIDEOS_FLOW_ID,
+ flowKind: 'student-videos',
+ header: '🎬 Student Videos',
+ body: ({
+ ur: 'اپنی کلاس، مضمون اور موضوع چنیں — میں ویڈیو آپ کی چیٹ میں بھیج دوں گا۔',
+ })[responseLanguage] || 'Pick a class, subject and topic — I will send the video to your chat.',
+ buttonText: ({
+ ur: 'تلاش کریں',
+ })[responseLanguage] || 'Browse',
+ flowToken: videoFlowToken,
+ });
+ if (pickerSent) {
+ logToFile('🎬 Sent student videos picker (/video)', { userId: user?.id });
return;
}
@@ -915,7 +944,7 @@ async function handleTextMessage(message, from, messageBody, user = null) {
.eq('status', 'conducting_conversation')
.order('created_at', { ascending: false })
.limit(1)
- .single();
+ .maybeSingle();
if (activeCoaching) {
// Check if session is stuck (no update in last hour)
@@ -984,7 +1013,7 @@ async function handleTextMessage(message, from, messageBody, user = null) {
.in('status', ['conducting_conversation', 'analyzing'])
.order('updated_at', { ascending: false })
.limit(1)
- .single();
+ .maybeSingle();
if (stuckSession) {
const lastUpdate = new Date(stuckSession.updated_at);
@@ -1235,18 +1264,15 @@ async function handleTextMessage(message, from, messageBody, user = null) {
}
}
- const SETTINGS_FLOW_ID = process.env.SETTINGS_FLOW_ID || '';
- if (!SETTINGS_FLOW_ID) {
- await WhatsAppService.sendMessage(from, ({
- ur: 'سیٹنگز ابھی دستیاب نہیں ہیں۔ بعد میں دوبارہ کوشش کریں۔',
- sw: 'Mipangilio bado haijapatikana. Tafadhali jaribu tena baadaye.',
- })[responseLanguage] || 'Settings are not available yet. Please try again later.');
- return;
- }
-
+ // Tried as a Meta Flow when one is published, and as the equivalent text
+ // conversation otherwise — both driven by the SAME settings endpoint, so
+ // preferences are written identically either way. The old code returned
+ // "Settings are not available yet" whenever SETTINGS_FLOW_ID was unset,
+ // which on the sandbox driver meant /settings could never do anything.
const flowToken = `${user?.id}:settings:${Date.now()}`;
- await WhatsAppService.sendFlow(from, {
- flowId: SETTINGS_FLOW_ID,
+ const settingsSent = await WhatsAppService.sendFlow(from, {
+ flowId: process.env.SETTINGS_FLOW_ID || '',
+ flowKind: 'settings',
header: 'Rumi Settings',
body: ({
ur: 'اپنی زبان اور آبزرویشن ٹول کی ترجیحات اپ ڈیٹ کریں۔',
@@ -1258,6 +1284,14 @@ async function handleTextMessage(message, from, messageBody, user = null) {
})[responseLanguage] || 'Open Settings',
flowToken
});
+
+ if (!settingsSent) {
+ await WhatsAppService.sendMessage(from, ({
+ ur: 'سیٹنگز ابھی دستیاب نہیں ہیں۔ زبان بدلنے کے لیے /language ٹائپ کریں۔',
+ sw: 'Mipangilio bado haijapatikana. Andika /language kubadilisha lugha.',
+ })[responseLanguage]
+ || 'Settings are not available on this deployment yet. Type /language to change your language.');
+ }
return;
}
@@ -1599,19 +1633,20 @@ async function handleTextMessage(message, from, messageBody, user = null) {
logToFile('📋 Add class keyword detected', { userId: user.id, keyword: addClassDetection.keyword });
typingController.stop();
- if (ATTENDANCE_SETUP_FLOW_ID) {
- await WhatsAppService.sendFlow(from, {
- flowId: ATTENDANCE_SETUP_FLOW_ID,
- header: '📋 Add New Class',
- body: "Let's set up a new class for attendance tracking!",
- buttonText: 'Add Class',
- screen: 'CLASS_INFO',
- flowToken: user.id // Pass user ID so endpoint can create class for correct user
- });
+ const addClassSent = await WhatsAppService.sendFlow(from, {
+ flowId: ATTENDANCE_SETUP_FLOW_ID,
+ flowKind: 'class-setup',
+ header: '📋 Add New Class',
+ body: "Let's set up a new class for attendance tracking!",
+ buttonText: 'Add Class',
+ screen: 'CLASS_INFO',
+ flowToken: user.id // Pass user ID so endpoint can create class for correct user
+ });
+ if (addClassSent) {
logToFile('📋 Sent add class flow', { userId: user.id, flowId: ATTENDANCE_SETUP_FLOW_ID });
} else {
await WhatsAppService.sendMessage(from, 'Sorry, class setup is not available right now. Please try again later.');
- logToFile('⚠️ ATTENDANCE_SETUP_FLOW_ID not configured', { userId: user.id });
+ logToFile('⚠️ Class setup unavailable on this channel', { userId: user.id });
}
return;
}
@@ -1626,22 +1661,22 @@ async function handleTextMessage(message, from, messageBody, user = null) {
const result = await AttendanceConversationService.startAttendanceSession(user.id);
if (result.action === 'SEND_SETUP_FLOW') {
- // User has no classes - send setup flow
- if (ATTENDANCE_SETUP_FLOW_ID) {
- // Send the WhatsApp Flow for class setup
- await WhatsAppService.sendFlow(from, {
- flowId: ATTENDANCE_SETUP_FLOW_ID,
- header: '📋 Class Setup',
- body: result.message,
- buttonText: 'Set Up Class',
- screen: 'CLASS_INFO',
- flowToken: user.id // Pass user ID so endpoint can create class for correct user
- });
+ // User has no classes — set one up, as a Flow or as the text equivalent.
+ const setupSent = await WhatsAppService.sendFlow(from, {
+ flowId: ATTENDANCE_SETUP_FLOW_ID,
+ flowKind: 'class-setup',
+ header: '📋 Class Setup',
+ body: result.message,
+ buttonText: 'Set Up Class',
+ screen: 'CLASS_INFO',
+ flowToken: user.id // Pass user ID so endpoint can create class for correct user
+ });
+ if (setupSent) {
logToFile('📋 Sent attendance setup flow', { userId: user.id, flowId: ATTENDANCE_SETUP_FLOW_ID });
} else {
- // Fallback if flow not configured - just send the message
+ // Neither a Flow nor a text flow is available — say what we know.
await WhatsAppService.sendMessage(from, result.message);
- logToFile('⚠️ ATTENDANCE_SETUP_FLOW_ID not configured, sent text message instead', { userId: user.id });
+ logToFile('⚠️ Class setup unavailable on this channel, sent text message instead', { userId: user.id });
}
} else if (result.action === 'ASK_CLASS_SELECTION' || result.action === 'ASK_MARKING_METHOD') {
await WhatsAppService.sendMessage(from, result.message);
@@ -1758,7 +1793,7 @@ async function handleTextMessage(message, from, messageBody, user = null) {
.eq('session_id', sessionId)
.order('created_at', { ascending: false })
.limit(1)
- .single();
+ .maybeSingle();
conversationState = conversation?.current_state || null; // Issue #41 FIX: VARCHAR, not nested JSONB
logToFile('Conversation state retrieved', { state: conversationState });
diff --git a/bot/shared/handlers/voice-message.handler.js b/bot/shared/handlers/voice-message.handler.js
index 15c79f1..e607e56 100644
--- a/bot/shared/handlers/voice-message.handler.js
+++ b/bot/shared/handlers/voice-message.handler.js
@@ -20,7 +20,7 @@ const {
storeAudioSession,
storeLessonPlan
} = require('../database/bot-helpers');
-const { uploadAudio } = require('../storage/r2');
+const { uploadAudio, isR2Configured } = require('../storage/r2');
const supabase = require('../config/supabase');
// Import language detection for content generation
const { detectRequestedLanguage } = require('../utils/language-detection');
@@ -414,7 +414,7 @@ async function handleVoiceMessage(message, from, user = null) {
.gte('created_at', thirtyMinutesAgo)
.order('created_at', { ascending: false })
.limit(1)
- .single();
+ .maybeSingle();
if (activeAssessment) {
// FIX 2: Validate assessment state to detect stale reads and invalid states
@@ -519,20 +519,34 @@ async function handleVoiceMessage(message, from, user = null) {
audioPath
});
- // Upload audio to R2
- const audioUrl = await uploadAudio(audioPath, user.id, audioId);
-
- logToFile('✓ Audio uploaded to R2', {
- assessmentId: activeAssessment.id,
- audioUrl
- });
-
- // Clean up temp file
- fs.unlinkSync(audioPath);
-
- logToFile('✓ Temp file cleaned up', {
- assessmentId: activeAssessment.id
- });
+ // Persist the audio so the QUEUED analysis step can still read it
+ // later (it downloads by URL — see reading/transcription.service.js).
+ //
+ // With object storage configured, that's R2. Without it, the recording
+ // stays on local disk and the URL is a file:// path. This matters
+ // because the upload used to be unconditional: on a deployment with no
+ // bucket it threw "S3Client cannot be constructed", which aborted the
+ // whole assessment with "🚨 CRITICAL: Reading assessment audio
+ // processing failed" — after the teacher had already recorded the
+ // student reading. A sandbox has no bucket by definition, so that was
+ // the entire reading feature, unusable.
+ //
+ // Local files are single-machine by nature: fine for a sandbox (the
+ // analysis runs in this same process), and R2 remains the right answer
+ // for a real deployment with workers on other hosts.
+ let audioUrl;
+ if (isR2Configured()) {
+ audioUrl = await uploadAudio(audioPath, user.id, audioId);
+ logToFile('✓ Audio uploaded to R2', { assessmentId: activeAssessment.id, audioUrl });
+ fs.unlinkSync(audioPath);
+ logToFile('✓ Temp file cleaned up', { assessmentId: activeAssessment.id });
+ } else {
+ audioUrl = `file://${audioPath}`;
+ logToFile('✓ Audio kept on local disk (no object storage configured)', {
+ assessmentId: activeAssessment.id, audioUrl,
+ });
+ // Deliberately NOT deleted — the analysis step reads it back.
+ }
// Stop typing indicator
typingController.stop();
@@ -600,7 +614,16 @@ async function handleVoiceMessage(message, from, user = null) {
const userLanguage = user.preferred_language || 'en';
const errorMessage = errorMessages[userLanguage] || errorMessages.en;
- await WhatsAppService.sendMessage(from, errorMessage);
+ // Skip when the analysis service already apologised (it sets this
+ // flag) — otherwise one failure produced two apologies in a row, plus
+ // a spoken one, which is what the teacher actually sees.
+ if (processingError.userNotified) {
+ logToFile('↩️ Reading assessment failure already reported to the user — not repeating it', {
+ assessmentId: activeAssessment.id,
+ });
+ } else {
+ await WhatsAppService.sendMessage(from, errorMessage);
+ }
// Re-throw to be caught by outer handler
throw processingError;
@@ -636,7 +659,7 @@ async function handleVoiceMessage(message, from, user = null) {
.eq('status', 'conducting_conversation')
.order('created_at', { ascending: false })
.limit(1)
- .single();
+ .maybeSingle();
if (activeCoaching) {
logToFile('🎓 Active coaching session detected - processing as reflective response', {
@@ -943,7 +966,7 @@ async function handleVoiceMessage(message, from, user = null) {
.eq('session_id', sessionId)
.order('created_at', { ascending: false })
.limit(1)
- .single();
+ .maybeSingle();
conversationState = conversation?.conversation_state?.current_state || null;
logToFile('Conversation state retrieved (voice)', { state: conversationState });
@@ -1040,20 +1063,42 @@ async function handleVoiceMessage(message, from, user = null) {
hasEmotionTags: /\[[\w]+\]/.test(aiResponse)
});
- // Step 7: Generate speech using appropriate TTS service based on language
+ // Step 7: Generate speech using appropriate TTS service based on language.
+ //
+ // A SPOKEN reply is an enhancement; understanding the voice note is the
+ // feature. So TTS failure must never lose the answer — found live on a
+ // deployment with no TTS keys: the voice note was transcribed and answered
+ // correctly, then ElevenLabs 401'd, its OpenAI-TTS fallback threw "OPENAI_API_KEY
+ // is required", and that exception propagated all the way out to the handler's
+ // catch, so the teacher received only "Sorry, an error occurred while
+ // processing the voice message" and never saw the answer at all.
logToFile('Step 7: Generating speech for language:', { language: detectedLanguage });
- const speechBuffer = await AudioService.generateSpeechForLanguage(aiResponse, detectedLanguage);
- logToFile('Speech generated', {
- bufferSize: speechBuffer.length,
- ttsService: detectedLanguage === 'en' ? 'ElevenLabs' : 'Uplift'
- });
+ let speechBuffer = null;
+ try {
+ speechBuffer = await AudioService.generateSpeechForLanguage(aiResponse, detectedLanguage);
+ logToFile('Speech generated', {
+ bufferSize: speechBuffer.length,
+ ttsService: detectedLanguage === 'en' ? 'ElevenLabs' : 'Uplift'
+ });
+ } catch (ttsError) {
+ logToFile('⚠️ Text-to-speech unavailable — replying in text instead', {
+ error: ttsError.message, language: detectedLanguage,
+ });
+ }
- // Step 8: Send audio response (stop typing indicator first)
- logToFile('Step 8: Sending audio response...');
+ // Step 8: Send the reply — spoken when we have audio, written otherwise.
+ logToFile('Step 8: Sending response...', { spoken: Boolean(speechBuffer) });
typingController.stop();
- await WhatsAppService.sendAudio(from, speechBuffer, TEMP_DIR);
+ if (speechBuffer) {
+ await WhatsAppService.sendAudio(from, speechBuffer, TEMP_DIR);
+ } else {
+ // sendMessage strips the [emotion] tags the voice prompt adds for TTS
+ // (_removeEmotionTags, in both channel drivers), so the written reply
+ // doesn't leak them.
+ await WhatsAppService.sendMessage(from, aiResponse);
+ }
- logToFile('✅ Voice acknowledgment sent successfully!');
+ logToFile('✅ Voice acknowledgment sent successfully!', { spoken: Boolean(speechBuffer) });
// Step 8.5: Send loading sticker if intent is presentation or lesson plan
if (intent.type === 'lesson_plan' || intent.type === 'presentation') {
@@ -1120,10 +1165,19 @@ async function handleVoiceMessage(message, from, user = null) {
errorDetails: error.response?.data
});
typingController.stop(); // Stop typing indicator before sending error message
- await WhatsAppService.sendMessage(
- from,
- 'معذرت، آواز پیغام پر کارروائی کرتے وقت خرابی آ گئی۔' // Sorry, error processing voice message
- );
+
+ // The failure may already have been explained to the user by a more specific
+ // handler further in (the reading-assessment analysis sets this flag). Adding
+ // a generic apology on top just means she gets told twice about one problem,
+ // the second time less usefully — and in Urdu regardless of her language.
+ if (error.userNotified) {
+ logToFile('↩️ Voice failure already reported to the user — not repeating it', {});
+ } else {
+ await WhatsAppService.sendMessage(
+ from,
+ 'معذرت، آواز پیغام پر کارروائی کرتے وقت خرابی آ گئی۔' // Sorry, error processing voice message
+ );
+ }
} finally {
// CRITICAL: Always stop typing indicator, even if function exits early or throws
typingController.stop();
diff --git a/bot/shared/routes/student-videos-endpoint.js b/bot/shared/routes/student-videos-endpoint.js
index d1038d2..f627c2f 100644
--- a/bot/shared/routes/student-videos-endpoint.js
+++ b/bot/shared/routes/student-videos-endpoint.js
@@ -220,7 +220,20 @@ function deliverVideoAsync(flowToken, row) {
}
const caption =
`📚 ${gradeTitle(row.grade)} · ${row.subject}\n${row.clean_title}`;
- await WhatsAppService.sendVideoFromUrl(phone, row.r2_url, caption);
+ // sendVideoFromUrl RETURNS false on failure rather than throwing, so the
+ // try/catch below never saw a failed upload: a teacher who received no
+ // video was still counted as delivered and then offered a quiz on it.
+ const delivered = await WhatsAppService.sendVideoFromUrl(phone, row.r2_url, caption);
+ if (!delivered) {
+ logToFile('Student Videos: video upload failed — not offering a quiz for it', {
+ userId, videoId: row.id, url: row.r2_url,
+ });
+ await WhatsAppService.sendMessage(
+ phone,
+ `Sorry — I couldn't send "${row.clean_title}" just now. Please try /video again in a moment.`
+ );
+ return;
+ }
logEvent('student_videos.delivered', {
userId,
videoId: row.id,
diff --git a/bot/shared/services/attendance-detector.service.js b/bot/shared/services/attendance-detector.service.js
index ab6efa8..6be3e8e 100644
--- a/bot/shared/services/attendance-detector.service.js
+++ b/bot/shared/services/attendance-detector.service.js
@@ -59,6 +59,16 @@ const ADD_CLASS_KEYWORDS = [
'another class',
'/addclass',
'/add-class',
+ // The exact phrases the bot ITSELF tells teachers to type — quiz-orchestrator
+ // .service.js says 'Type "set up class" or say "class setup" to get started!'
+ // when a quiz has no class to go to. None of them were listed here, so
+ // following the bot's own instruction fell through to general AI chat and the
+ // teacher could never get past it.
+ 'set up class',
+ 'setup class',
+ 'class setup',
+ 'set up my class',
+ 'set up a class',
// Urdu (Arabic script)
'نئی کلاس',
diff --git a/bot/shared/services/cache/railway-redis.service.js b/bot/shared/services/cache/railway-redis.service.js
index 64794ef..e93672c 100644
--- a/bot/shared/services/cache/railway-redis.service.js
+++ b/bot/shared/services/cache/railway-redis.service.js
@@ -20,6 +20,16 @@ const Redis = require('ioredis');
const { logToFile } = require('../../utils/logger');
const { RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_SECONDS } = require('../../utils/constants');
+/**
+ * Upper bound for setexWithCeiling(). Conversational state (a quiz in progress,
+ * a pending prompt) belongs to the day it was created; a longer-lived key is a
+ * leak rather than a cache.
+ */
+const TTL_CEILING_SECONDS = 24 * 60 * 60;
+
+/** Fallback TTL for setNX() when the caller passes no usable one. */
+const SETNX_DEFAULT_TTL_SECONDS = 60;
+
class RailwayRedisService {
constructor() {
if (!process.env.REDIS_URL) {
@@ -628,6 +638,86 @@ class RailwayRedisService {
}
}
+ /**
+ * SET if Not eXists, with a TTL — an atomic "claim this key" primitive.
+ *
+ * image-message.handler.js uses it as the idempotency guard at the very top of
+ * runImageAnalysis(), NOT inside a try/catch of its own — so with the method
+ * missing, every inbound image threw immediately and the teacher got the
+ * generic "something went wrong" error. Image analysis could not work at all.
+ *
+ * Distinct from acquireLock(), which claims a lock whose value is a lock id it
+ * later verifies before releasing; this stores the caller's own value (the
+ * in-flight state that the duplicate path reads back) and simply expires.
+ *
+ * @param {string} key
+ * @param {string} value
+ * @param {number} ttlSeconds
+ * @returns {Promise} true when THIS caller claimed the key. False when
+ * another already holds it — and also when Redis is unavailable, since a
+ * deployment without Redis must still process the image rather than treat
+ * every one as a duplicate... see the note below.
+ */
+ async setNX(key, value, ttlSeconds) {
+ if (!this.isAvailable()) {
+ // No Redis means no cross-process idempotency to enforce. Returning TRUE
+ // (claimed) is the safe answer: the caller proceeds with the work. False
+ // would make it believe a duplicate is already in flight and skip it.
+ return true;
+ }
+
+ // An absent, zero or negative TTL is a caller bug, not a request for a
+ // 1-second key — fall back to a sane default instead of a claim that expires
+ // before the work it guards has started.
+ const requested = Number(ttlSeconds);
+ const ttl = Number.isFinite(requested) && requested > 0
+ ? Math.floor(requested)
+ : SETNX_DEFAULT_TTL_SECONDS;
+
+ try {
+ const result = await this.redis.set(key, value, 'EX', ttl, 'NX');
+ return result === 'OK';
+ } catch (error) {
+ logToFile('Failed to setNX', { key, error: error.message });
+ // Same reasoning as above: a Redis failure must not silently drop work.
+ return true;
+ }
+ }
+
+ /**
+ * setex with the TTL clamped to a 24-hour maximum.
+ *
+ * The quiz subsystem calls this for every piece of its conversational state —
+ * the active session, the delivery queue, the post-quiz prompt, the follow-up
+ * topic wait (quiz-session/quiz-delivery/quiz-follow-up.service.js, 10+ call
+ * sites) — and it was never implemented, so EVERY one of them threw
+ * "redisService.setexWithCeiling is not a function". Found live: picking a
+ * class after /quiz answered "Sorry, something went wrong. Please try /quiz
+ * again", and no quiz could ever be delivered on any deployment.
+ *
+ * The ceiling is what the name promises: quiz state describes an in-flight
+ * conversation, so a key that outlives the day it was created is a leak, not a
+ * cache. Callers already assume it (quiz-follow-up.service.js: "1h —
+ * comfortably under the 24h ceiling").
+ *
+ * @param {string} key
+ * @param {number} seconds requested TTL; clamped to [1, 86400]
+ * @param {string} value
+ * @returns {Promise} false when Redis is unavailable or the write failed
+ */
+ async setexWithCeiling(key, seconds, value) {
+ const requested = Number(seconds);
+ const ttl = Number.isFinite(requested) && requested > 0
+ ? Math.min(Math.floor(requested), TTL_CEILING_SECONDS)
+ : TTL_CEILING_SECONDS;
+
+ if (ttl !== requested) {
+ logToFile('Clamped Redis TTL to the 24h ceiling', { key, requested: seconds, ttl });
+ }
+
+ return this.setex(key, ttl, value);
+ }
+
// ============================================================================
// MONITORING & ADMIN
// ============================================================================
diff --git a/bot/shared/services/coaching/coaching-helpers.service.js b/bot/shared/services/coaching/coaching-helpers.service.js
index b0d57fb..adf01d6 100644
--- a/bot/shared/services/coaching/coaching-helpers.service.js
+++ b/bot/shared/services/coaching/coaching-helpers.service.js
@@ -11,10 +11,8 @@
* Extracted from coaching.service.js as part of Phase 2 refactoring
*/
-const OpenAI = require('openai');
const supabase = require('../../config/supabase');
const { logToFile } = require('../../utils/logger');
-const { OPENAI_API_KEY } = require('../../utils/constants');
class CoachingHelpersService {
/**
@@ -26,7 +24,9 @@ class CoachingHelpersService {
static async generateEncouragingMessage(firstName, durationSeconds) {
try {
const durationMinutes = Math.round(durationSeconds / 60);
- const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
+ // See the note in pic-to-lp/classifier.service.js: llm-client is the single
+ // LLM entry point, and a direct OpenAI client breaks an OpenRouter deployment.
+ const openai = require('../llm-client').getClient();
const response = await openai.chat.completions.create({
model: 'gpt-4o',
diff --git a/bot/shared/services/coaching/transcript-enhancer.service.js b/bot/shared/services/coaching/transcript-enhancer.service.js
index d3dbfb7..21cf8e5 100644
--- a/bot/shared/services/coaching/transcript-enhancer.service.js
+++ b/bot/shared/services/coaching/transcript-enhancer.service.js
@@ -16,13 +16,16 @@
const OpenAI = require('openai');
const { logToFile } = require('../../utils/logger');
const { logEvent } = require('../../utils/structured-logger');
-const { lazyClient } = require('../../utils/lazy-client');
// Lazy-initialised: the enhancer only runs when a coaching transcript is being
// post-processed. Setting OPENAI_API_KEY is optional at boot time.
-const getOpenAI = lazyClient(OpenAI, ['OPENAI_API_KEY'], (env) => ({
- apiKey: env.OPENAI_API_KEY,
-}));
+// Routed through llm-client, the single LLM entry point this codebase
+// documents: it points at whichever provider is configured (OpenRouter by
+// default) and prefixes bare model names for it. A direct OpenAI client
+// demanded OPENAI_API_KEY, which an OpenRouter deployment does not set — so
+// this failed with "SDK client cannot be constructed — missing env:
+// OPENAI_API_KEY" while the rest of the bot's LLM calls worked.
+const { getClient: getOpenAI } = require('../llm-client');
/**
* Phonetic Urdu → English Dictionary
diff --git a/bot/shared/services/exam-checker/exam-session.service.js b/bot/shared/services/exam-checker/exam-session.service.js
index 475904b..e81a583 100644
--- a/bot/shared/services/exam-checker/exam-session.service.js
+++ b/bot/shared/services/exam-checker/exam-session.service.js
@@ -275,12 +275,19 @@ class ExamSessionService {
// ==================== REDIS HELPERS ====================
+ // These go through railway-redis.service's own API (get/setex/delete), which
+ // is already null-safe when REDIS_URL is unset. They previously called
+ // `redisService.getClient()` — a method that does not exist — and then
+ // `redis.setEx`, which is the node-redis spelling, not ioredis's `setex`. The
+ // try/catch hid both, so every read and write here failed silently and the
+ // cache never once worked; each call did a wasted round trip to nothing before
+ // falling back to the database.
+
static async _getFromRedis(userId) {
try {
- const redis = await redisService.getClient();
- const key = `${REDIS_PREFIX}${userId}`;
- const data = await redis.get(key);
- return data ? JSON.parse(data) : null;
+ const data = await redisService.get(`${REDIS_PREFIX}${userId}`);
+ if (!data) return null;
+ return typeof data === 'string' ? JSON.parse(data) : data;
} catch (error) {
logToFile('⚠️ Redis get failed', { userId, error: error.message });
return null;
@@ -289,9 +296,7 @@ class ExamSessionService {
static async _saveToRedis(userId, session) {
try {
- const redis = await redisService.getClient();
- const key = `${REDIS_PREFIX}${userId}`;
- await redis.setEx(key, REDIS_TTL, JSON.stringify(session));
+ await redisService.setex(`${REDIS_PREFIX}${userId}`, REDIS_TTL, JSON.stringify(session));
} catch (error) {
logToFile('⚠️ Redis save failed', { userId, error: error.message });
}
@@ -299,9 +304,7 @@ class ExamSessionService {
static async _clearFromRedis(userId) {
try {
- const redis = await redisService.getClient();
- const key = `${REDIS_PREFIX}${userId}`;
- await redis.del(key);
+ await redisService.delete(`${REDIS_PREFIX}${userId}`);
} catch (error) {
logToFile('⚠️ Redis clear failed', { userId, error: error.message });
}
diff --git a/bot/shared/services/feature-linker.service.js b/bot/shared/services/feature-linker.service.js
index 54f9f23..01e2eee 100644
--- a/bot/shared/services/feature-linker.service.js
+++ b/bot/shared/services/feature-linker.service.js
@@ -170,9 +170,16 @@ class FeatureLinkerService {
// Check if user has seen the intro video for this feature
const hasSeenVideo = await FeatureIntroService.hasSeenIntroVideo(userId, link.feature);
- if (hasSeenVideo) {
- // User already saw video, just send text suggestion
- logToFile('📝 User already saw video, sending text only', { feature: link.feature });
+ // No intro video available on this deployment (FEATURE_VIDEO_URLS is
+ // null when R2_PUBLIC_URL is unset) — so offering to show one is a
+ // promise that cannot be kept. Send the plain text suggestion instead
+ // of a "Want to see how? 🎥" button that leads nowhere.
+ const hasVideoToShow = Boolean(FEATURE_VIDEO_URLS[link.feature]);
+
+ if (hasSeenVideo || !hasVideoToShow) {
+ logToFile('📝 Sending text-only feature suggestion', {
+ feature: link.feature, hasSeenVideo, hasVideoToShow,
+ });
await WhatsAppService.sendMessage(phoneNumber, textMessage);
} else {
// User hasn't seen video - ask for consent via interactive buttons
diff --git a/bot/shared/services/messaging/baileys-channel.service.js b/bot/shared/services/messaging/baileys-channel.service.js
new file mode 100644
index 0000000..d83d7fe
--- /dev/null
+++ b/bot/shared/services/messaging/baileys-channel.service.js
@@ -0,0 +1,801 @@
+/**
+ * Baileys channel driver — sandbox-tier, zero Meta setup required.
+ *
+ * Real sending/receiving over a WhatsApp Web connection (via
+ * baileys-connection.js), for every method that has a genuine Baileys
+ * equivalent. A handful of methods are Meta-template-specific concepts with
+ * NO Baileys equivalent — WhatsApp template approval, carousels — because
+ * their real content (the template's static wording) lives only in Meta's
+ * registered template config, not in the `components`/payload arguments this
+ * driver receives; porting them needs the channel-agnostic template registry
+ * from docs/onboarding/sandbox-production-design.md §1, which isn't built
+ * yet. Those stay honest stubs (documented per-method below), never a crash.
+ *
+ * Method names/async-ness are parsed statically off meta-channel.service.js's
+ * SOURCE (regex, same mechanism as tests/setup/no-undefined-whatsapp-methods
+ * .test.js and tests/messaging/channel-driver-parity.test.js) rather than by
+ * `require()`-ing that module — meta-channel.service.js pulls in axios/
+ * form-data, neither of which this driver needs. If meta-channel.service.js
+ * ever grows a new method this file doesn't know about, requiring this file
+ * throws immediately with a clear message (see the assertion loop at the
+ * bottom) rather than silently shipping a missing/wrong-shaped member.
+ *
+ * Media handling: Baileys has no Meta-style "upload once, fetch by ID later"
+ * API — incoming media only exists as bytes attached to the message object
+ * at the moment it's received. baileys-socket.adapter.js (the inbound
+ * listener, not yet wired into whatsapp-bot.js) downloads it immediately and
+ * calls cacheIncomingMedia() so getMediaInfo()/downloadMedia() — called later
+ * by the same handler pipeline Meta uses — can still look it up by the
+ * synthetic ID the adapter assigned. Until that adapter exists, nothing
+ * populates this cache and both methods correctly report "not found."
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { logToFile } = require('../../utils/logger');
+const { downloadFromR2, extractKeyFromUrl } = require('../../storage/r2');
+const connection = require('./baileys-connection');
+const pendingOptions = require('./pending-options');
+const textFlow = require('./text-flow');
+
+const META_SOURCE_PATH = path.join(__dirname, 'meta-channel.service.js');
+
+function parseMembers(src) {
+ const members = [];
+ const methodRe = /^\s*static\s+(async\s+)?(\w+)\s*\(/gm;
+ let m;
+ while ((m = methodRe.exec(src))) members.push({ name: m[2], isAsync: !!m[1] });
+ return members;
+}
+
+const MEMBERS = parseMembers(fs.readFileSync(META_SOURCE_PATH, 'utf-8'));
+
+/**
+ * Builds a chat JID from a phone number.
+ *
+ * Drops any `@server` and `:device` suffix BEFORE stripping non-digits. Order
+ * matters — a live bug: Baileys 7.x can hand back device-scoped JIDs like
+ * `:0@s.whatsapp.net`, and stripping non-digits first turned that into
+ * `0`, silently sending to the real number plus a trailing zero (a
+ * nonexistent destination Baileys still reports as "sent"). Guarded here as
+ * well as at the inbound edge, since this is the last gate before a send.
+ */
+function toJid(phoneNumber) {
+ const bare = String(phoneNumber).split('@')[0].split(':')[0];
+ const digits = bare.replace(/\D/g, '');
+ return `${digits}@s.whatsapp.net`;
+}
+
+async function getSock() {
+ return connection.getSocket();
+}
+
+// ── Inbound media bridge (see file header) ──────────────────────────────────
+const MEDIA_CACHE_TTL_MS = 10 * 60 * 1000; // generous for a multi-step handler pipeline
+const mediaCache = new Map(); // mediaId -> { buffer, mimetype, cachedAt }
+
+function cacheIncomingMedia(mediaId, buffer, mimetype) {
+ mediaCache.set(mediaId, { buffer, mimetype, cachedAt: Date.now() });
+}
+
+function getCachedMedia(mediaId) {
+ const entry = mediaCache.get(mediaId);
+ if (!entry) return null;
+ if (Date.now() - entry.cachedAt > MEDIA_CACHE_TTL_MS) {
+ mediaCache.delete(mediaId);
+ return null;
+ }
+ return entry;
+}
+
+function mediaNotFoundError(mediaId) {
+ return new Error(
+ `Baileys channel driver: no cached media for id "${mediaId}" — Baileys has no fetch-by-id API, `
+ + 'media must be consumed shortly after it is received (see baileys-socket.adapter.js)'
+ );
+}
+
+// ── Plain-text rendering for Meta's interactive-UI methods ──────────────────
+// Baileys' native button/list messages are unreliable across current
+// WhatsApp clients (widely reported across the ecosystem) — a numbered plain
+// text list is the honest, actually-reliable choice: it always renders, at
+// the cost of the user typing a reply instead of tapping one. Matching that
+// typed reply back to a choice is an inbound-adapter concern, not this file's.
+/**
+ * True when the description just restates the title ("English" → "English
+ * language") and so adds nothing but noise. Kept when it DOES add information —
+ * notably a Latin gloss for a non-Latin title ("اردو" → "Urdu language"), which
+ * is what makes that option typeable by name.
+ */
+function isRedundantDescription(title, description) {
+ if (!description) return true;
+ const t = String(title).trim().toLowerCase();
+ const d = String(description).trim().toLowerCase();
+ return d === t || d === `${t} language` || d.startsWith(t);
+}
+
+/**
+ * A name from this very menu to use in the "or the name" hint.
+ *
+ * The SHORTEST option's identifying half (the part after "·" in a composite
+ * "Group · Item" label), so the hint stays short while remaining something the
+ * user can type back verbatim and have it actually match — an elided example
+ * would not. A fixed '"English"' was actively confusing on a list of grades.
+ */
+function exampleName(options) {
+ const names = (options || [])
+ .map((o) => String(o?.title || '').split('·').pop().trim())
+ // Single characters count: a quiz question's options are literally "A", "B",
+ // "C", and excluding them fell through to the hardcoded '"English"' example
+ // on a multiple-choice question.
+ .filter((name) => name.length >= 1);
+ if (!names.length) return 'English';
+ return names.reduce((shortest, name) => (name.length < shortest.length ? name : shortest));
+}
+
+function renderOptionsAsText({ header, body, footer, options }) {
+ const lines = [];
+ if (header) lines.push(`*${header}*`);
+ if (body) lines.push(body);
+ lines.push('');
+ options.forEach((opt, i) => {
+ const gloss = isRedundantDescription(opt.title, opt.description) ? '' : ` — ${opt.description}`;
+ lines.push(`${i + 1}. ${opt.title}${gloss}`);
+ });
+ if (footer) { lines.push(''); lines.push(`_${footer}_`); }
+ lines.push('');
+ // Both forms are accepted (see pending-options.js#resolveSelection) — demanding
+ // a number is unrealistic when people naturally type the name instead. The
+ // example is drawn from THIS menu: a fixed '"2" or "English"' was actively
+ // confusing on a list of school grades.
+ lines.push(`Reply with a number or the name — e.g. "1" or "${exampleName(options)}".`);
+ return lines.join('\n');
+}
+
+/**
+ * Records the menu just rendered so the user's numeric reply can be turned back
+ * into the interactive reply whatsapp-bot.js's router dispatches on.
+ *
+ * Without this the numbered list is display-only: "1" arrives as ordinary text,
+ * never matches the `interactive.button_reply`/`list_reply` branches, and falls
+ * through to general AI chat. See pending-options.js for the full rationale.
+ *
+ * Options are stored in RENDER ORDER, so option N is what the user typing N
+ * means — the same array must be passed here and to renderOptionsAsText().
+ * Best-effort: never let bookkeeping failure block the send.
+ *
+ * @param {string} to
+ * @param {'button_reply'|'list_reply'} replyType
+ * @param {Array<{id?: string, title: string}>} options
+ */
+async function rememberMenu(to, replyType, options) {
+ const withIds = (options || []).filter((o) => o && o.id);
+ if (!withIds.length) return; // nothing routable to map a number back to
+ await pendingOptions.remember(String(to), {
+ replyType,
+ options: withIds.map((o) => ({ id: o.id, title: o.title })),
+ });
+}
+
+// ── Media sources ────────────────────────────────────────────────────────────
+
+function isAbsoluteHttpUrl(url) {
+ return /^https?:\/\//i.test(String(url || ''));
+}
+
+/** The credentials downloadFromR2() needs; it throws when any is missing. */
+function isR2Configured() {
+ return Boolean(
+ process.env.R2_ENDPOINT && process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY
+ );
+}
+
+/**
+ * Resolves a media URL into something Baileys can send: either a Buffer pulled
+ * through the authenticated R2 client, or `{ url }` for Baileys to stream itself.
+ *
+ * Both are needed, and which one is right depends on the URL, not on the code
+ * path. Live testing found this the hard way: every *FromUrl sender routed
+ * unconditionally through downloadFromR2(), so /video died with "S3Client cannot
+ * be constructed — missing env: R2_ENDPOINT…" on a video whose URL was a
+ * PUBLIC bucket URL that needs no credentials whatsoever. A sandbox is exactly
+ * the deployment that has no R2 keys, and the imported content library is
+ * exactly the content served from public URLs.
+ *
+ * Handing Baileys `{ url }` is also strictly better where it applies: it streams
+ * the media instead of buffering the whole file in this process's memory.
+ *
+ * @param {string} url
+ * @returns {Promise}
+ */
+async function resolveMediaSource(url) {
+ // A file:// URL is media this deployment generated locally because it has no
+ // bucket to put it in (a reading-assessment report PDF, say — see
+ // reading/analysis.service.js). Read it straight off disk.
+ if (typeof url === 'string' && url.startsWith('file://')) {
+ const localPath = url.slice('file://'.length);
+ if (!fs.existsSync(localPath)) {
+ throw new Error(`Cannot send media: local file is gone (${localPath})`);
+ }
+ return fs.readFileSync(localPath);
+ }
+
+ if (isR2Configured()) {
+ try {
+ return await downloadFromR2(extractKeyFromUrl(url));
+ } catch (error) {
+ // A configured R2 doesn't mean every URL lives in it (a public CDN URL
+ // from another bucket, say) — so fall through when we can fetch directly.
+ if (!isAbsoluteHttpUrl(url)) throw error;
+ logToFile('⚠️ Baileys: R2 download failed — fetching the URL directly instead', {
+ url, error: error.message,
+ });
+ }
+ }
+
+ if (!isAbsoluteHttpUrl(url)) {
+ throw new Error(
+ `Cannot send media from "${url}": it is not an absolute URL, and R2 is not configured `
+ + '(set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY to read private objects).'
+ );
+ }
+
+ return { url };
+}
+
+// ── WhatsApp Flows, degraded to a conversation ───────────────────────────────
+
+/**
+ * A Flow's kind, derived from the flow token when the caller didn't name it.
+ *
+ * Callers already build tokens as `${userId}:${kind}:${timestamp}` (the
+ * convention student-videos, settings and attendance all follow), so the middle
+ * segment identifies the Flow without every call site having to be touched.
+ * `flowKind` is preferred where it is passed — explicit beats inferred, and Meta
+ * ignores the extra option.
+ */
+function kindFromToken(flowToken) {
+ const parts = String(flowToken || '').split(':');
+ return parts.length >= 2 ? parts[1] : null;
+}
+
+/**
+ * Sends one rendered text-flow step. Exported for the inbound adapter, which
+ * renders every step after the first.
+ *
+ * text-flow.js#renderStep has already recorded the menu in pending-options, so
+ * the reply — a number OR the option's name — resolves without extra
+ * bookkeeping here.
+ */
+async function sendTextFlowStep(to, render) {
+ const { header, body, footer } = render.prompt || {};
+
+ if (render.kind === 'menu') {
+ return sendMessage(to, renderOptionsAsText({ header, body, footer, options: render.options }));
+ }
+
+ // 'text' (a free-text question) and 'empty' (the endpoint had nothing to
+ // offer, and body carries its own explanation) are both plain messages.
+ const lines = [];
+ if (header) lines.push(`*${header}*`);
+ if (body) lines.push(body);
+ if (footer) lines.push(`_${footer}_`);
+ return sendMessage(to, lines.join('\n\n') || 'Please reply to continue.');
+}
+
+/**
+ * The sandbox stand-in for a Meta Flow: starts the registered text flow of the
+ * same kind and asks its first question.
+ *
+ * Returning true/false matters — callers branch on it (text-message.handler.js's
+ * /reading test throws "Failed to send WhatsApp Flow" on false, which surfaces
+ * to the user as "Sorry, something went wrong"). So: true whenever the user was
+ * given something actionable, false only when this driver genuinely has no text
+ * equivalent for the Flow, letting the caller run its own fallback.
+ */
+async function sendFlow(to, options = {}) {
+ // eslint-disable-next-line global-require -- lazy on purpose; see ensureRegistered()
+ require('./text-flow-definitions').ensureRegistered();
+
+ const kind = options.flowKind || kindFromToken(options.flowToken);
+ const definition = kind ? textFlow.getDefinition(kind) : null;
+
+ if (!definition) {
+ logToFile('Baileys channel driver: no text flow registered for this Flow — falling back to the caller', {
+ driver: 'baileys', flowKind: options.flowKind || null, derivedKind: kind,
+ });
+ return false;
+ }
+
+ const flowToken = options.flowToken || '';
+ const context = {
+ _ctx: { userId: flowToken.split(':')[0] || null, flowToken, phone: String(to) },
+ };
+
+ const render = await textFlow.start(String(to), kind, {}, context);
+ if (!render) return false;
+
+ await sendTextFlowStep(to, render);
+ logToFile('▶️ Baileys: Flow degraded to a text flow', { to, kind, step: render.kind });
+ return true;
+}
+
+// ── Real implementations ─────────────────────────────────────────────────────
+
+async function sendMessage(to, message) {
+ try {
+ const cleanMessage = IMPLEMENTATIONS._removeEmotionTags(message);
+ const sock = await getSock();
+ await sock.sendMessage(toJid(to), { text: cleanMessage });
+ logToFile('✅ Baileys message sent', { to });
+ return true;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending message', { error: error.message });
+ return false;
+ }
+}
+
+async function sendReaction(to, messageId, emoji = '❤️') {
+ try {
+ const sock = await getSock();
+ // fromMe: false — sendReaction is always called to react to the OTHER
+ // party's incoming message (see whatsapp-bot.js's WhatsAppService.sendReaction(from, message.id, emoji) call).
+ await sock.sendMessage(toJid(to), {
+ react: { text: emoji, key: { remoteJid: toJid(to), id: messageId, fromMe: false } },
+ });
+ return true;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending reaction', { error: error.message });
+ return false;
+ }
+}
+
+async function showTypingIndicator(to) {
+ try {
+ const sock = await getSock();
+ await sock.sendPresenceUpdate('composing', toJid(to));
+ return true;
+ } catch (error) {
+ logToFile('❌ Baileys: error showing typing indicator', { error: error.message });
+ return false;
+ }
+}
+
+function startContinuousTypingIndicator(to) {
+ showTypingIndicator(to).catch(() => {});
+ const intervalId = setInterval(() => { showTypingIndicator(to).catch(() => {}); }, 20000);
+ return { stop: () => clearInterval(intervalId) };
+}
+
+async function getMediaInfo(mediaId) {
+ const entry = getCachedMedia(mediaId);
+ if (!entry) throw mediaNotFoundError(mediaId);
+ return { url: null, mime_type: entry.mimetype, file_size: entry.buffer.length };
+}
+
+async function downloadMedia(mediaId) {
+ const entry = getCachedMedia(mediaId);
+ if (!entry) throw mediaNotFoundError(mediaId);
+ return entry.buffer;
+}
+
+const DOCUMENT_MIME_TYPES = {
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ xls: 'application/vnd.ms-excel',
+ pdf: 'application/pdf',
+ doc: 'application/msword',
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+};
+
+async function sendDocumentBuffer(to, buffer, filename, caption) {
+ const ext = filename.toLowerCase().split('.').pop();
+ const mimetype = DOCUMENT_MIME_TYPES[ext] || 'application/octet-stream';
+ const sock = await getSock();
+ await sock.sendMessage(toJid(to), {
+ document: buffer, mimetype, fileName: filename, caption,
+ });
+ return true;
+}
+
+async function sendDocument(to, filePath, filename, caption) {
+ try {
+ const buffer = fs.readFileSync(filePath);
+ return await sendDocumentBuffer(to, buffer, filename, caption);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending document', { error: error.message });
+ return false;
+ }
+}
+
+async function sendAudio(to, audioBuffer) {
+ try {
+ const sock = await getSock();
+ await sock.sendMessage(toJid(to), { audio: audioBuffer, mimetype: 'audio/mpeg', ptt: false });
+ return true;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending audio', { error: error.message });
+ return false;
+ }
+}
+
+async function sendDocumentFromUrl(to, documentUrl, filename, caption) {
+ try {
+ const media = await resolveMediaSource(documentUrl);
+ return await sendDocumentBuffer(to, media, filename, caption);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending document from URL', { error: error.message, documentUrl });
+ return false;
+ }
+}
+
+async function sendAudioFromUrl(to, audioUrl) {
+ try {
+ const media = await resolveMediaSource(audioUrl);
+ return await sendAudio(to, media);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending audio from URL', { error: error.message, audioUrl });
+ return false;
+ }
+}
+
+// ── *ReturningId variants ───────────────────────────────────────────────────
+// These return the sent message's ID (or null) instead of a boolean, because
+// callers thread that ID back in as a reply/quote target. Meta returns it as
+// data.messages[0].id; Baileys returns it as the sent message's key.id.
+
+async function sendTextReturningId(to, message, opts = {}) {
+ try {
+ const cleanMessage = IMPLEMENTATIONS._removeEmotionTags(message);
+ const sock = await getSock();
+
+ // Meta quotes by bare message ID. Baileys wants a message object, so build
+ // the minimal stub its contextInfo builder needs. Best-effort: if it can't
+ // resolve the target the message still sends, just unquoted.
+ const sendOpts = {};
+ if (opts.contextMessageId) {
+ sendOpts.quoted = {
+ key: { remoteJid: toJid(to), id: opts.contextMessageId, fromMe: false },
+ message: {},
+ };
+ }
+
+ const sent = await sock.sendMessage(toJid(to), { text: cleanMessage }, sendOpts);
+ const id = sent?.key?.id || null;
+ logToFile('✅ Baileys message sent (returning id)', { to, id });
+ return id;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending message (returning id)', { error: error.message });
+ return null;
+ }
+}
+
+async function sendAudioFromUrlReturningId(to, audioUrl) {
+ try {
+ const media = await resolveMediaSource(audioUrl);
+ const sock = await getSock();
+ const sent = await sock.sendMessage(toJid(to), {
+ audio: media, mimetype: 'audio/mp4', ptt: true,
+ });
+ const id = sent?.key?.id || null;
+ logToFile('✅ Baileys audio sent (returning id)', { to, id });
+ return id;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending audio from URL (returning id)', { error: error.message, audioUrl });
+ return null;
+ }
+}
+
+async function sendImageBuffer(to, buffer, caption) {
+ const sock = await getSock();
+ await sock.sendMessage(toJid(to), { image: buffer, caption });
+ return true;
+}
+
+async function sendImageFromUrl(to, imageUrl, caption = '') {
+ try {
+ const media = await resolveMediaSource(imageUrl);
+ return await sendImageBuffer(to, media, caption);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending image from URL', { error: error.message, imageUrl });
+ return false;
+ }
+}
+
+async function sendVideo(to, videoBuffer, tempDir, caption = '') {
+ try {
+ const sock = await getSock();
+ await sock.sendMessage(toJid(to), { video: videoBuffer, caption: caption || undefined });
+ return true;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending video', { error: error.message });
+ return false;
+ }
+}
+
+async function sendVideoFromUrl(to, videoUrl, caption = '') {
+ try {
+ const media = await resolveMediaSource(videoUrl);
+ return await sendVideo(to, media, null, caption);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending video from URL', { error: error.message, videoUrl });
+ return false;
+ }
+}
+
+async function sendImage(to, mediaIdOrPath, caption = '') {
+ const isFilePath = mediaIdOrPath.includes('/') || mediaIdOrPath.includes('\\');
+ if (!isFilePath) {
+ logToFile(
+ '❌ Baileys: sendImage was given a Meta media ID, not a file path/URL — Baileys has no '
+ + 'reusable media-ID upload step, so cached-ID reuse is not supported on this channel',
+ { mediaIdOrPath }
+ );
+ return false;
+ }
+ try {
+ const buffer = fs.readFileSync(mediaIdOrPath);
+ return await sendImageBuffer(to, buffer, caption);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending image', { error: error.message });
+ return false;
+ }
+}
+
+async function sendSticker(to, mediaIdOrPath) {
+ const isFilePath = mediaIdOrPath.includes('/') || mediaIdOrPath.includes('\\');
+ if (!isFilePath) {
+ logToFile('❌ Baileys: sendSticker was given a Meta media ID, not a file path — not supported on this channel', { mediaIdOrPath });
+ return false;
+ }
+ if (!fs.existsSync(mediaIdOrPath)) {
+ logToFile('Sticker file not found — skipping sticker send (cosmetic)', { path: mediaIdOrPath });
+ return false;
+ }
+ try {
+ const buffer = fs.readFileSync(mediaIdOrPath);
+ const sock = await getSock();
+ await sock.sendMessage(toJid(to), { sticker: buffer });
+ return true;
+ } catch (error) {
+ logToFile('❌ Baileys: error sending sticker', { error: error.message });
+ return false;
+ }
+}
+
+async function sendInteractiveButtons(to, options) {
+ try {
+ const { body, buttons } = options;
+ const text = renderOptionsAsText({ body, options: buttons.map((b) => ({ title: b.title })) });
+ await rememberMenu(to, 'button_reply', buttons);
+ return await sendMessage(to, text);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending interactive buttons (text fallback)', { error: error.message });
+ return false;
+ }
+}
+
+async function sendImageWithButtons(to, imageUrl, bodyText, buttons) {
+ try {
+ const media = await resolveMediaSource(imageUrl);
+ const caption = renderOptionsAsText({ body: bodyText, options: buttons.map((b) => ({ title: b.title })) });
+ await rememberMenu(to, 'button_reply', buttons);
+ return await sendImageBuffer(to, media, caption);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending image with buttons (text fallback)', { error: error.message, imageUrl });
+ return false;
+ }
+}
+
+async function sendInteractiveMessage(to, listData) {
+ try {
+ const { header, body, footer, action } = listData;
+ const { sections } = action || {};
+ const options = (sections || []).flatMap((s) => s.rows || []);
+ const text = renderOptionsAsText({
+ header: header?.text || header,
+ body: body?.text || body,
+ footer: footer?.text || footer,
+ options,
+ });
+ await rememberMenu(to, 'list_reply', options);
+ return await sendMessage(to, text);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending interactive list (text fallback)', { error: error.message });
+ return false;
+ }
+}
+
+// `id` values MUST match meta-channel.service.js's rows exactly — they are what
+// whatsapp-bot.js's `listId.startsWith('lang_')` branch dispatches on, and
+// rememberMenu() below stores them so a numeric reply resolves to the same id
+// Meta's native list picker would have sent.
+const DEFAULT_PICKER_CODES = ['en', 'ur', 'pa-PK', 'sd-PK', 'ps-PK', 'bal-PK', 'ta-LK', 'ar', 'es'];
+
+/**
+ * Resolves which language codes to offer, from `config/supported-languages.js`
+ * and the region's config (fail-open) — the SAME source of truth Meta's driver
+ * uses, deliberately NOT a hardcoded list.
+ *
+ * This mattered: the first version of this driver hardcoded 10 languages copied
+ * from whatsapp.service.js as it looked before `feat(languages): add
+ * Indian-language support` landed on main. That silently dropped hi/bn/mr/te/
+ * ta-IN/kn for anyone in the India region. Reading the shared config means new
+ * languages appear here automatically.
+ *
+ * Unlike Meta, there is NO 10-row cap to respect — this renders as plain text,
+ * so every language the region supports can be listed.
+ */
+async function resolveLanguageOptions(region = null) {
+ // eslint-disable-next-line global-require -- lazy, matching this file's convention
+ const { LANGUAGES, SUPPORTED_LANGUAGES } = require('../../config/supported-languages');
+
+ let codes = DEFAULT_PICKER_CODES;
+ try {
+ // eslint-disable-next-line global-require -- lazy: avoids a DB-backed service on module load
+ const RegionFeaturesService = require('../region-features.service');
+ const feats = await RegionFeaturesService.getRegionFeatures(region);
+ const fromRegion = Array.isArray(feats.supported_languages)
+ ? feats.supported_languages.filter((c) => SUPPORTED_LANGUAGES.includes(c))
+ : [];
+ // Only trust the region list when it is more specific than the trivial
+ // ['en'] fail-open default.
+ if (fromRegion.length > 1) codes = fromRegion;
+ } catch (error) {
+ logToFile('Baileys language picker: region lookup failed, using default set', { error: error.message });
+ }
+
+ return [
+ { id: 'lang_auto', title: 'Auto-detect', description: 'Let me detect your language automatically' },
+ ...codes.map((code) => ({
+ id: `lang_${code}`,
+ title: LANGUAGES[code]?.native || code,
+ description: `${LANGUAGES[code]?.english || code} language`,
+ })),
+ ];
+}
+
+async function sendLanguageSelectionList(to, currentLanguage = 'en', region = null) {
+ try {
+ const options = await resolveLanguageOptions(region);
+ const text = renderOptionsAsText({
+ header: 'Select Language / زبان منتخب کریں',
+ body: 'Choose your preferred language. I will respond in this language for all conversations.',
+ footer: 'You can change this anytime by typing /language',
+ options,
+ });
+ await rememberMenu(to, 'list_reply', options);
+ return await sendMessage(to, text);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending language selection list', { error: error.message });
+ return false;
+ }
+}
+
+const STYLE_OPTIONS = [
+ { id: 'style_photorealistic', title: 'Photorealistic', description: 'Camera-quality, HDR, 8K realistic images' },
+ { id: 'style_infographic', title: 'Infographic', description: 'TED-Ed/Kurzgesagt flat vector style' },
+ { id: 'style_cartoon', title: 'Cartoon', description: 'Pixar-inspired animated characters' },
+ { id: 'style_sketch', title: 'Sketch', description: 'Whiteboard hand-drawn style' },
+];
+
+async function sendStyleListFallback(to) {
+ try {
+ const text = renderOptionsAsText({ header: '🎨 Choose Video Style', options: STYLE_OPTIONS });
+ await rememberMenu(to, 'list_reply', STYLE_OPTIONS);
+ return await sendMessage(to, text);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending style list fallback', { error: error.message });
+ return false;
+ }
+}
+
+const FEATURE_MENU_OPTIONS = [
+ { id: 'menu_lesson_plan', title: 'Lesson Plans', description: 'Create detailed PDF lesson plans' },
+ { id: 'menu_coaching', title: 'Classroom Coaching', description: 'Get teaching feedback from recordings' },
+ { id: 'menu_reading', title: 'Reading Assessment', description: 'Test student reading fluency' },
+ { id: 'menu_video', title: 'AI Video Generation', description: 'Create educational videos' },
+ { id: 'menu_other', title: 'Ask Anything', description: 'General teaching questions' },
+];
+
+async function sendFeatureMenuListFallback(to) {
+ try {
+ const text = renderOptionsAsText({ header: "Here's what I can do!", options: FEATURE_MENU_OPTIONS });
+ await rememberMenu(to, 'list_reply', FEATURE_MENU_OPTIONS);
+ return await sendMessage(to, text);
+ } catch (error) {
+ logToFile('❌ Baileys: error sending feature menu list fallback', { error: error.message });
+ return false;
+ }
+}
+
+function notSupportedMessage(methodName) {
+ return `Baileys channel driver: ${methodName}() has no equivalent yet — it needs the channel-agnostic `
+ + 'template registry from docs/onboarding/sandbox-production-design.md §1, which is not built. '
+ + 'The template\'s static wording lives only in Meta\'s registered config, not in this call\'s arguments.';
+}
+
+// ── Explicit method table ────────────────────────────────────────────────────
+// Every parsed member (see MEMBERS below) must appear in exactly one of these
+// two tables, checked by the assertion loop below.
+
+const IMPLEMENTATIONS = {
+ _removeEmotionTags(text) {
+ return text.replace(/\[[a-zA-Z\s]+\]\s*/g, '').trim();
+ },
+ sendMessage,
+ sendReaction,
+ showTypingIndicator,
+ startContinuousTypingIndicator,
+ getMediaInfo,
+ downloadMedia,
+ sendDocument,
+ sendAudio,
+ sendDocumentFromUrl,
+ sendAudioFromUrl,
+ sendTextReturningId,
+ sendAudioFromUrlReturningId,
+ sendImageFromUrl,
+ sendVideo,
+ sendVideoFromUrl,
+ sendImage,
+ sendSticker,
+ sendInteractiveButtons,
+ sendImageWithButtons,
+ sendInteractiveMessage,
+ sendLanguageSelectionList,
+ sendStyleListFallback,
+ sendFeatureMenuListFallback,
+ sendFlow,
+};
+
+// name -> whether the real (Meta) method is async, so the stub shape matches.
+const STUBS = {
+ sendTemplate: true,
+ sendStyleCarousel: true,
+ sendFeatureMenuCarousel: true,
+ buildStyleCarouselPayload: false,
+ buildFeatureMenuCarouselPayload: false,
+};
+
+function asyncFalseStub(methodName) {
+ return async function baileysStub(...args) {
+ logToFile(notSupportedMessage(methodName), { methodName, driver: 'baileys' });
+ return false;
+ };
+}
+
+function syncNullStub(methodName) {
+ return function baileysSyncStub(...args) {
+ logToFile(notSupportedMessage(methodName), { methodName, driver: 'baileys' });
+ return null;
+ };
+}
+
+const BaileysChannel = {};
+
+for (const { name, isAsync } of MEMBERS) {
+ if (Object.prototype.hasOwnProperty.call(IMPLEMENTATIONS, name)) {
+ BaileysChannel[name] = IMPLEMENTATIONS[name];
+ } else if (Object.prototype.hasOwnProperty.call(STUBS, name)) {
+ const stubIsAsync = STUBS[name];
+ if (stubIsAsync !== isAsync) {
+ throw new Error(
+ `baileys-channel.service.js: "${name}" is registered as ${stubIsAsync ? 'async' : 'sync'} in STUBS but `
+ + `meta-channel.service.js now declares it ${isAsync ? 'async' : 'sync'} — update STUBS to match.`
+ );
+ }
+ BaileysChannel[name] = stubIsAsync ? asyncFalseStub(name) : syncNullStub(name);
+ } else {
+ throw new Error(
+ `baileys-channel.service.js: meta-channel.service.js declares "${name}" with no matching entry in `
+ + 'IMPLEMENTATIONS or STUBS. Add one so this driver never silently lacks a method the rest of the bot calls.'
+ );
+ }
+}
+
+BaileysChannel._cacheIncomingMedia = cacheIncomingMedia;
+BaileysChannel._toJid = toJid;
+BaileysChannel._sendTextFlowStep = sendTextFlowStep;
+BaileysChannel._kindFromToken = kindFromToken;
+
+module.exports = BaileysChannel;
diff --git a/bot/shared/services/messaging/baileys-connection.js b/bot/shared/services/messaging/baileys-connection.js
new file mode 100644
index 0000000..55587f0
--- /dev/null
+++ b/bot/shared/services/messaging/baileys-connection.js
@@ -0,0 +1,513 @@
+/**
+ * Baileys connection manager — the ONE place that owns the persistent
+ * WhatsApp Web socket. Both bot/scripts/setup/baileys-pair.js (the standalone
+ * pairing script) and baileys-channel.service.js (the driver the running bot
+ * uses to send) share this, via getSocket() — whichever runs first connects;
+ * whichever runs after reuses the same connection, so pairing state is never
+ * duplicated.
+ *
+ * `baileys` (via ./baileys-lib.js — see that file for why it's a separate
+ * module, not an inlined dynamic import) and `qrcode-terminal` are loaded
+ * LAZILY, inside connect(), not at module top level. This matters for two
+ * reasons: (1) it matches this repo's existing lazy-client convention (see
+ * shared/storage/r2.js's lazyClient) — nothing here should force-load a
+ * heavy dependency before it's actually needed; (2) it means simply
+ * requiring this file (or baileys-channel.service.js, which requires this
+ * file) never touches the real `baileys` package, so root-suite tests that
+ * only check method presence/shape don't need to mock it — only tests that
+ * actually exercise connect()/getSocket() do (see
+ * tests/messaging/baileys-connection.test.js).
+ *
+ * `events` (the exported `events` EventEmitter, below) is the one and ONLY
+ * place a caller should observe connection lifecycle — NOT a specific
+ * socket's own `sock.ev`. Real-world discovery (a live pairing run against
+ * WhatsApp's actual servers): after the QR is scanned, Baileys closes the
+ * socket with "restart required" (code 515) and this module transparently
+ * reconnects internally, creating a NEW socket object. A caller that
+ * attached its success listener to the FIRST sock's `ev` (as
+ * baileys-pair.js originally did) never sees the second socket's real
+ * "open" event and times out despite the pairing having actually succeeded
+ * — this `events` emitter is registered once per connect() call from
+ * inside the SAME internal listener that already correctly fires across
+ * every reconnect, so it never goes stale.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const EventEmitter = require('events');
+const { logToFile } = require('../../utils/logger');
+
+const AUTH_SUBDIR = 'baileys';
+
+// The repo root, four levels up from bot/shared/services/messaging.
+const REPO_ROOT = path.resolve(__dirname, '../../../..');
+
+/**
+ * Where this driver keeps its session.
+ *
+ * Anchored to the repo, NOT to process.cwd(). A relative CHANNEL_STATE_DIR
+ * resolved against the working directory means the session moves when you do:
+ * `cd bot && npm start` used a *different, empty* folder, so Baileys registered
+ * a second device and re-synced from scratch — endlessly, and with two devices
+ * fighting over one account. Seen live: `.channel-state/baileys` (device :13) at
+ * the repo root and `bot/.channel-state/baileys` (device :14) side by side.
+ *
+ * An absolute CHANNEL_STATE_DIR is honoured as given.
+ */
+function authDir() {
+ const root = process.env.CHANNEL_STATE_DIR || '.channel-state';
+ return path.isAbsolute(root)
+ ? path.join(root, AUTH_SUBDIR)
+ : path.resolve(REPO_ROOT, root, AUTH_SUBDIR);
+}
+
+let socketPromise = null;
+const connectionState = { connected: false };
+const events = new EventEmitter();
+
+// ── Single-instance guard on the auth folder ─────────────────────────────────
+//
+// Two processes must never share one Baileys auth folder. What happens if they
+// do, observed live: both connect with the same credentials, WhatsApp rejects
+// the duplicate with "Stream Errored (conflict)", and the SESSION IS
+// INVALIDATED — not just the losing process. Recovering needs a human with the
+// phone to re-scan a QR.
+//
+// This is not an exotic race. It happens whenever restarts overlap even
+// slightly: a supervisor that restarts on exit, a PaaS rolling deploy where the
+// old container is still draining while the new one boots, or (as here) an
+// operator restarting faster than the previous process died.
+//
+// So: claim the folder, and refuse to start if someone live already holds it.
+// Failing to boot is enormously better than destroying the pairing.
+const LOCK_FILENAME = '.instance.lock';
+
+function lockPath() {
+ return path.join(authDir(), LOCK_FILENAME);
+}
+
+/**
+ * A pino-shaped logger that discards everything, for the interactive commands.
+ * Prefers real pino (already a dependency, so its exact interface is honoured)
+ * and falls back to a stub if it cannot be loaded — a missing logger must never
+ * be the reason pairing fails.
+ *
+ * @returns {object}
+ */
+function quietBaileysLogger() {
+ try {
+ return require('pino')({ level: 'silent' });
+ } catch {
+ const noop = () => {};
+ const stub = {
+ level: 'silent', fatal: noop, error: noop, warn: noop, info: noop, debug: noop, trace: noop,
+ };
+ stub.child = () => stub;
+ return stub;
+ }
+}
+
+/** True when a process with this pid exists and we may signal it. */
+function pidIsAlive(pid) {
+ if (!Number.isInteger(pid) || pid <= 0) return false;
+ try {
+ process.kill(pid, 0); // signal 0 = existence check only
+ return true;
+ } catch (error) {
+ // EPERM means it exists but belongs to another user — still alive.
+ return error.code === 'EPERM';
+ }
+}
+
+let lockHeld = false;
+
+/**
+ * Claims the auth folder for this process.
+ *
+ * @throws {Error} when a live process already holds it.
+ */
+function acquireInstanceLock() {
+ if (lockHeld) return;
+ fs.mkdirSync(authDir(), { recursive: true });
+
+ const file = lockPath();
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ // 'wx' fails if the file exists — atomic claim, no check-then-write race.
+ const fd = fs.openSync(file, 'wx');
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, since: new Date().toISOString() }));
+ fs.closeSync(fd);
+ lockHeld = true;
+ return;
+ } catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+
+ let holder = null;
+ try {
+ holder = JSON.parse(fs.readFileSync(file, 'utf-8'));
+ } catch {
+ holder = null; // unreadable/corrupt — treat as stale
+ }
+
+ if (holder && pidIsAlive(holder.pid) && holder.pid !== process.pid) {
+ throw new Error(
+ `Another Rumi instance (pid ${holder.pid}, since ${holder.since}) is already using `
+ + `${authDir()}. Two processes sharing one WhatsApp session make WhatsApp invalidate it, `
+ + 'which requires re-pairing by hand — so this one is stopping instead. '
+ + 'Stop the other instance first.'
+ );
+ }
+
+ // Stale (holder dead, or it is us after a crash) — take it over.
+ logToFile('Baileys: taking over a stale instance lock', { staleHolder: holder });
+ try { fs.unlinkSync(file); } catch { /* another process just cleaned it */ }
+ }
+ }
+ throw new Error(`Could not claim the Baileys instance lock at ${lockPath()}`);
+}
+
+function releaseInstanceLock() {
+ if (!lockHeld) return;
+ lockHeld = false;
+ try {
+ const holder = JSON.parse(fs.readFileSync(lockPath(), 'utf-8'));
+ if (holder.pid !== process.pid) return; // not ours any more; leave it alone
+ } catch {
+ return; // already gone
+ }
+ try { fs.unlinkSync(lockPath()); } catch { /* already gone */ }
+}
+
+// A crash or a signal must not leave a lock that blocks the next start. The
+// stale-pid takeover above is the real backstop; this just keeps things tidy.
+process.once('exit', releaseInstanceLock);
+
+// Set by close() so the connection.update 'close' branch below knows the
+// disconnect was deliberate and must NOT trigger the usual auto-reconnect —
+// sock.end() emits the same 'close' event a network blip does.
+let shuttingDown = false;
+
+/**
+ * Recently-sent messages, so Baileys can answer RETRY RECEIPTS.
+ *
+ * The real bug this fixes, found live: the paired phone showed "Waiting for
+ * this message. This may take a while." on the bot's replies, permanently.
+ * WhatsApp's recovery path for an undecryptable message is a retry receipt —
+ * the recipient asks the sender to re-encrypt and resend. Baileys implements
+ * that in sendMessagesAgain() (Socket/messages-recv.js), which calls the
+ * `getMessage` config hook to recover the ORIGINAL content before resending:
+ *
+ * const msgs = await Promise.all(ids.map(id => getMessage({ ...key, id })));
+ * ...
+ * for (const [i, msg] of msgs.entries()) { if (msg) { ...resend... } }
+ *
+ * The library default is `async () => undefined` (Defaults/index.js), and
+ * Baileys leaves a TODO saying the consumer must supply the store ("implement
+ * a cache to store the last 256 sent messages"). Without it every `msg` is
+ * undefined, so nothing is EVER resent and the placeholder never resolves —
+ * which is exactly what we observed: the retry receipts arrived and the bot
+ * had no way to answer them.
+ *
+ * Bounded to the same 256 Baileys' TODO suggests, oldest-evicted-first (Map
+ * preserves insertion order). Deliberately in-memory only: this holds decrypted
+ * outgoing message content, which should not be written to disk. Losing it on
+ * restart is fine — a retry for a pre-restart message can't be honoured anyway.
+ */
+const SENT_MESSAGE_STORE_MAX = 256;
+const sentMessages = new Map(); // message id -> proto.IMessage
+
+function rememberSentMessage(sent) {
+ if (!sent?.key?.id || !sent.message) return;
+ sentMessages.set(sent.key.id, sent.message);
+ while (sentMessages.size > SENT_MESSAGE_STORE_MAX) {
+ sentMessages.delete(sentMessages.keys().next().value);
+ }
+}
+
+/** The `getMessage` hook Baileys calls when a recipient requests a resend. */
+async function getStoredMessage(key) {
+ return sentMessages.get(key?.id);
+}
+
+/**
+ * Serialises every sock.sendMessage() call so no two overlap on this socket,
+ * and records each result for the retry store above.
+ *
+ * The serialisation is the important half. Concurrent sends on a single
+ * Baileys socket corrupt Signal ratchet state: two encryptions can advance
+ * from the same chain key, and the loser produces ciphertext the recipient
+ * cannot decrypt — it shows "Waiting for this message. This may take a while."
+ * This bot sends several messages per inbound message (a reaction, a typing
+ * presence update, then the text reply), and startContinuousTypingIndicator
+ * keeps firing on a timer WHILE the reply is being sent, so overlap is the
+ * normal case here, not an edge case.
+ *
+ * Confirmed independently: NousResearch's hermes-agent WhatsApp bridge hit the
+ * same failure and fixed it the same way, noting "overlapping sends are the
+ * root cause of cross-chat contamination — the WhatsApp protocol-level routing
+ * can misdeliver when two sendMessage() Promises race on the same socket."
+ *
+ * Wrapped here, once, rather than at ~20 call sites in
+ * baileys-channel.service.js — every outbound path already funnels through the
+ * socket this module hands out.
+ */
+function trackSentMessages(sock) {
+ const originalSendMessage = sock.sendMessage.bind(sock);
+ let sendQueue = Promise.resolve();
+
+ sock.sendMessage = (...args) => {
+ // Chain on both fulfilment and rejection so one failed send never wedges
+ // the queue for every subsequent one.
+ const task = sendQueue.then(
+ () => originalSendMessage(...args),
+ () => originalSendMessage(...args)
+ ).then((sent) => {
+ rememberSentMessage(sent);
+ return sent;
+ });
+ sendQueue = task.catch(() => {});
+ return task;
+ };
+
+ return sock;
+}
+
+/**
+ * Resolves ONLY once the connection is actually open — NOT as soon as
+ * makeWASocket() returns a socket shell. Real-world discovery (a live send
+ * attempt): makeWASocket() returns synchronously, long before the WebSocket
+ * handshake/auth completes; a caller that did `sock = await connect();
+ * sock.sendMessage(...)` immediately hit "Connection Closed" because the
+ * transport genuinely wasn't open yet. Every consumer (baileys-channel
+ * .service.js's sendMessage/etc.) goes through getSocket(), so fixing the
+ * resolution semantics here fixes it everywhere at once.
+ *
+ * If the socket closes (non-logout) before ever reaching "open" — e.g. the
+ * very first attempt hits a transient error — this attempt's promise chains
+ * onto the fresh reconnect this module kicks off internally, rather than
+ * hanging forever unresolved (the same "stale reference" bug class the
+ * `events` emitter above fixes for listeners, applied to the promise itself).
+ *
+ * @param {object} [opts]
+ * @param {(qr: string) => void} [opts.onQr] called with the raw QR payload
+ * whenever WhatsApp issues one (unpaired or session expired) — in addition
+ * to the terminal render this function always does.
+ * @returns {Promise}
+ */
+async function connect(opts = {}) {
+ const {
+ makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, DisconnectReason,
+ } = await require('./baileys-lib').loadBaileys();
+ const qrcodeTerminal = require('qrcode-terminal');
+
+ // Claim the auth folder BEFORE touching it. Throws (rather than corrupting a
+ // live session) if another instance already holds it.
+ acquireInstanceLock();
+
+ // Whether this process STARTED with credentials. Load-bearing below: a QR is
+ // normal and wanted when there are none (first-time pairing), but a QR when
+ // creds existed means the session was invalidated server-side, and re-pairing
+ // needs a human with the phone. Captured before useMultiFileAuthState(), which
+ // creates the directory.
+ const hadCredentials = fs.existsSync(path.join(authDir(), 'creds.json'));
+
+ const { state, saveCreds } = await useMultiFileAuthState(authDir());
+ const { version, isLatest } = await fetchLatestBaileysVersion();
+ logToFile('Baileys: connecting', { version, isLatest, authDir: authDir() });
+
+ const socketConfig = {
+ auth: state,
+ version,
+ // Lets Baileys honour retry receipts — without it a recipient that fails
+ // to decrypt is stuck on "Waiting for this message" forever (see the
+ // sentMessages doc comment above). Still defaults to a no-op in 7.x.
+ getMessage: getStoredMessage,
+ // Baileys 7.x flipped this default to TRUE. Rumi only ever acts on
+ // messages that arrive while it is running, so pulling the operator's
+ // entire chat history on every connect is pure cost (and on a large
+ // account, a slow, memory-hungry one).
+ syncFullHistory: false,
+ // Do NOT mark the account "online" just because the bot connected — that
+ // suppresses push notifications on the operator's own phone for the whole
+ // time the bot is up. hermes-agent's bridge sets this for the same reason.
+ markOnlineOnConnect: false,
+ // Shows up in WhatsApp → Linked Devices, so the operator can tell which
+ // entry is this bot rather than an anonymous "Chrome (Ubuntu)".
+ browser: ['Rumi', 'Chrome', '120.0'],
+ };
+
+ // Baileys logs its whole handshake at info level through its own default
+ // logger. On the server that is useful; in `rumi pair` it put ~15 lines of raw
+ // JSON immediately above and below the QR code, burying the one thing on
+ // screen the user had to use.
+ //
+ // Set as a separate key, NOT as `logger: isCli ? quiet : undefined`. Baileys
+ // merges config over its defaults, so an explicit `undefined` *overwrites* its
+ // default logger rather than leaving it alone — and the next thing it does is
+ // call `logger.child()`. That took the bot's entire WhatsApp connection down
+ // with "Cannot read properties of undefined (reading 'child')" while every
+ // other service reported healthy.
+ if (process.env.RUMI_CLI === '1') socketConfig.logger = quietBaileysLogger();
+
+ const sock = trackSentMessages(makeWASocket(socketConfig));
+
+ sock.ev.on('creds.update', saveCreds);
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+
+ sock.ev.on('connection.update', (update) => {
+ const { connection, lastDisconnect, qr } = update;
+
+ if (qr) {
+ // A QR when we ALREADY had credentials is not a pairing opportunity —
+ // the session was invalidated (WhatsApp reports this as
+ // "Stream Errored (conflict)", typically because two processes shared one
+ // auth folder, e.g. an old instance still alive during a redeploy).
+ // Baileys then re-issues a QR every ~20s forever. Nobody is watching this
+ // terminal, so the only thing that achieves is hammering WhatsApp's
+ // pairing endpoint — which is precisely how this project kept tripping
+ // the "can't link new devices right now" rate limit. Treat it exactly
+ // like a logout: terminal, needs a human, stop trying.
+ if (hadCredentials && !opts.allowRepair) {
+ logToFile('🔒 Baileys: session was invalidated — re-pairing needs a human, not a retry loop', {
+ authDir: authDir(),
+ remedy: `delete ${authDir()} and run: npm run pair:baileys`,
+ });
+ events.emit('close', { statusCode: DisconnectReason.loggedOut, loggedOut: true });
+ // Don't render or re-request. Ending the socket stops the QR cycle.
+ try { sock.end(new Error('session invalidated — re-pairing required')); } catch { /* already closing */ }
+ if (!settled) { settled = true; reject(new Error('Baileys session invalidated — re-pair required')); }
+ return;
+ }
+
+ qrcodeTerminal.generate(qr, { small: true });
+ logToFile('📱 Baileys: scan this QR code with WhatsApp (Linked Devices) to pair', {});
+ if (opts.onQr) opts.onQr(qr);
+ events.emit('qr', qr);
+ }
+
+ if (connection === 'open') {
+ connectionState.connected = true;
+ logToFile('✅ Baileys: connected', {});
+ events.emit('open');
+ if (!settled) { settled = true; resolve(sock); }
+ }
+
+ if (connection === 'close') {
+ connectionState.connected = false;
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
+ const loggedOut = statusCode === DisconnectReason.loggedOut;
+ logToFile('⚠️ Baileys: connection closed', { statusCode, loggedOut });
+ events.emit('close', { statusCode, loggedOut });
+
+ if (shuttingDown) {
+ // Deliberate close() during process shutdown — reconnecting here
+ // would resurrect the socket we are trying to shut down.
+ logToFile('Baileys: closed for shutdown — not reconnecting', {});
+ if (!settled) { settled = true; reject(new Error('Baileys: shutting down')); }
+ } else if (loggedOut) {
+ logToFile('🔒 Baileys: session logged out — delete the auth folder and re-pair', { authDir: authDir() });
+ socketPromise = null;
+ if (!settled) { settled = true; reject(new Error('Baileys: logged out before connecting')); }
+ } else {
+ // Not a logout (network blip, the expected post-pairing "restart
+ // required", etc.) — reconnect. No exponential backoff / storm
+ // protection here by design: v1 targets local/dev quick-start, not
+ // hosted-at-scale resilience (see
+ // docs/onboarding/sandbox-production-design.md's out-of-scope list).
+ socketPromise = null;
+ const reconnectPromise = getSocket(opts);
+ if (!settled) {
+ settled = true;
+ reconnectPromise.then(resolve, reject);
+ } else {
+ reconnectPromise.catch((err) => logToFile('❌ Baileys: reconnect failed', { error: err.message }));
+ }
+ }
+ }
+ });
+ });
+}
+
+/** Lazily connects on first call; subsequent calls reuse the same connection. Resolves once actually open. */
+function getSocket(opts) {
+ if (!socketPromise) socketPromise = connect(opts);
+ return socketPromise;
+}
+
+function isConnected() {
+ return connectionState.connected;
+}
+
+/**
+ * Closes the socket cleanly WITHOUT logging out (the pairing survives), then
+ * waits briefly so Baileys' auth-state writes can land on disk.
+ *
+ * Why the wait matters — a real bug this fixes, found live: the process was
+ * killed by SIGTERM with no shutdown handling at all. `useMultiFileAuthState`
+ * persists Signal session/ratchet state to CHANNEL_STATE_DIR/baileys with
+ * fire-and-forget async fs writes, so an abrupt exit loses whatever hadn't
+ * flushed. On restart Baileys loaded stale ratchet state and encrypted with
+ * keys the paired phone no longer expected — the phone could not decrypt the
+ * bot's replies and showed "Waiting for this message. This may take a while."
+ * indefinitely. Every message sent before the kill decrypted fine; everything
+ * after it was stuck, which is what pinned the cause to the unclean exit.
+ *
+ * This matters in production, not just locally: a PaaS redeploy (Railway,
+ * Fly, Docker stop, k8s rollout) sends SIGTERM on EVERY deploy, so without
+ * this the sandbox driver risks desyncing live users' sessions each release.
+ *
+ * @param {object} [opts]
+ * @param {number} [opts.flushMs=500] grace period for pending auth-state writes.
+ */
+async function close({ flushMs = 500 } = {}) {
+ shuttingDown = true;
+ const pending = socketPromise;
+ socketPromise = null;
+
+ if (pending) {
+ try {
+ const sock = await pending;
+ sock.end(undefined); // undefined = clean close, NOT a logout
+ } catch {
+ // Never reached "open" (or already rejected) — nothing to close.
+ }
+ }
+
+ connectionState.connected = false;
+ logToFile('Baileys: connection closed for shutdown, flushing auth state', { flushMs });
+ await new Promise((resolve) => setTimeout(resolve, flushMs));
+
+ // Released only AFTER the flush window, so a supervisor that restarts the
+ // instant this resolves cannot begin writing the auth folder while our final
+ // Signal-state writes are still landing.
+ releaseInstanceLock();
+}
+
+/** Test-only: forces the next getSocket() call to reconnect from scratch. */
+function _resetForTests() {
+ socketPromise = null;
+ connectionState.connected = false;
+ shuttingDown = false;
+ lockHeld = false;
+ sentMessages.clear();
+ events.removeAllListeners();
+}
+
+module.exports = {
+ getSocket,
+ isConnected,
+ close,
+ authDir,
+ events,
+ lockPath,
+ acquireInstanceLock,
+ // Exported for its contract test: Baileys is handed this object, so the shape
+ // has to stay pino-compatible even on the no-pino fallback path.
+ quietBaileysLogger,
+ releaseInstanceLock,
+ getStoredMessage,
+ rememberSentMessage,
+ _resetForTests,
+};
diff --git a/bot/shared/services/messaging/baileys-lib.js b/bot/shared/services/messaging/baileys-lib.js
new file mode 100644
index 0000000..2618110
--- /dev/null
+++ b/bot/shared/services/messaging/baileys-lib.js
@@ -0,0 +1,23 @@
+/**
+ * ESM-interop wrapper for the `baileys` package.
+ *
+ * `baileys` ships as pure ESM (`"type": "module"`, no `exports`/CJS interop
+ * field) — `require('baileys')` throws `ERR_REQUIRE_ESM` on Node < 20.19 /
+ * 22.12. Dynamic `import()` is the correct interop path from CJS and works
+ * on every Node version this repo supports (>=18).
+ *
+ * This is its own module (rather than `await import('baileys')` inlined at
+ * each call site) specifically so tests can `jest.doMock` THIS file: Jest's
+ * mocking replaces the whole module before its body ever runs, so the real
+ * `import()` below — which this repo's Jest config can't execute at all
+ * (see tests/jest.config.js's `experimentalVmModules: false`) — is never
+ * reached in a test. Real callers (baileys-connection.js,
+ * inbound/baileys-socket.adapter.js) always go through loadBaileys().
+ */
+
+/** @returns {Promise} */
+async function loadBaileys() {
+ return import('baileys');
+}
+
+module.exports = { loadBaileys };
diff --git a/bot/shared/services/messaging/channel-registry.js b/bot/shared/services/messaging/channel-registry.js
new file mode 100644
index 0000000..c7f1888
--- /dev/null
+++ b/bot/shared/services/messaging/channel-registry.js
@@ -0,0 +1,35 @@
+/**
+ * channel-registry — static map of known messaging channel drivers.
+ *
+ * Adding a channel (e.g. Slack) is a two-line change: a DRIVERS entry pointing
+ * at its service module, and — only if it requires a formal business
+ * registration process the way Meta does — an addition to
+ * PRODUCTION_TIER_DRIVERS. Every driver not listed there is sandbox-tier by
+ * default; no per-driver tagging to remember.
+ *
+ * This file holds data only, no env/process logic, so it has zero
+ * dependencies and can be required from anywhere (feature-availability.js,
+ * messaging/index.js, doctor.js) without risk of a require cycle.
+ */
+
+const DRIVERS = {
+ meta: './meta-channel.service',
+ baileys: './baileys-channel.service',
+};
+
+const DEFAULT_DRIVER = 'baileys';
+
+// Drivers that require a formal business registration / app-review process —
+// the thing that actually makes a channel "production-grade" here. Every
+// driver NOT in this list is sandbox-tier by default.
+const PRODUCTION_TIER_DRIVERS = ['meta'];
+
+function isKnownDriver(name) {
+ return Object.prototype.hasOwnProperty.call(DRIVERS, name);
+}
+
+function isProductionTier(name) {
+ return PRODUCTION_TIER_DRIVERS.includes(name);
+}
+
+module.exports = { DRIVERS, DEFAULT_DRIVER, PRODUCTION_TIER_DRIVERS, isKnownDriver, isProductionTier };
diff --git a/bot/shared/services/messaging/endpoint-text-flow.js b/bot/shared/services/messaging/endpoint-text-flow.js
new file mode 100644
index 0000000..35fbe1c
--- /dev/null
+++ b/bot/shared/services/messaging/endpoint-text-flow.js
@@ -0,0 +1,164 @@
+/**
+ * Turns an existing WhatsApp Flow *endpoint* into a text conversation.
+ *
+ * Why this is a builder and not a re-implementation: every data-exchange Flow
+ * in bot/shared/routes/*-endpoint.js already has the same shape —
+ *
+ * INIT -> { screen, data: { , ...values } }
+ * data_exchange(screen, data) -> { screen, data: { , ...values } }
+ * | { data: { error: { message } } }
+ *
+ * — and all of the real work (the DB queries, the validation, the actual
+ * video send, the preference write) lives inside those functions. A Meta Flow
+ * is only a *renderer* for them. So the sandbox doesn't need a second copy of
+ * the business logic; it needs a second renderer, which is what this is: it
+ * asks one question per screen field, resolves the reply by number-or-name,
+ * accumulates `screenData` exactly as the Flow client would, and calls the
+ * very same endpoint functions. Bugs fixed in the endpoint are fixed for both
+ * channels, and a new Flow degrades to text by declaring a config here.
+ *
+ * Mapping from a Flow definition to a config:
+ * screen -> stage.screen (the value passed to data_exchange)
+ * a field -> stage.fields[] (one chat question each)
+ * dropdown data-source key -> field.optionsKey (e.g. `grades`, `languages`)
+ * field name in screenData -> field.id (e.g. `grade`, `language`)
+ *
+ * @module endpoint-text-flow
+ */
+
+const { logToFile } = require('../../utils/logger');
+
+/** The endpoint's own "nothing to offer / bad input" channel. */
+function errorMessageOf(response) {
+ return response?.data?.error?.message || null;
+}
+
+/**
+ * Rows a Flow dropdown would have bound to, normalised to {id, title}.
+ * Endpoints already emit {id, title} (that's what a Flow dropdown requires),
+ * so this is a guard against a stray shape rather than a transformation.
+ */
+function rowsFrom(response, optionsKey) {
+ const raw = response?.data?.[optionsKey];
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map((row) => (typeof row === 'string'
+ ? { id: row, title: row }
+ : { id: String(row.id ?? row.value ?? ''), title: String(row.title ?? row.id ?? '') }))
+ .filter((row) => row.id && row.title);
+}
+
+/**
+ * The screenData a Flow client would have submitted: every field answered so
+ * far, keyed by field id, valued by the chosen option's id.
+ */
+function screenDataFrom(answers) {
+ const screenData = {};
+ for (const [fieldId, answer] of Object.entries(answers || {})) {
+ if (fieldId.startsWith('_')) continue; // reserved (seeded, non-field values)
+ screenData[fieldId] = answer?.id;
+ }
+ return screenData;
+}
+
+/**
+ * @param {object} config
+ * @param {string} config.kind registry key, e.g. 'student-videos'
+ * @param {(ctx) => Promise