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.

- Features · Quick Start · + Features · + Languages · + Content Library · Agent-Native · - Customize · Docs · Website

@@ -23,6 +24,7 @@ CI Node.js WhatsApp + 15 minute setup Agent-native

@@ -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 areNo 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 lessonA 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 readA student reads aloud into a voice note; Rumi returns words-per-minute, accuracy, pronunciation and comprehension against grade benchmarks.
Ships with real content890 curriculum videos, 10,929 QA-certified questions, 15,557 voice clips and 3,217 illustrations — free, CDN-hosted, one command to import.
Speaks their language15 languages for chat, voice-note transcription and spoken replies — including a full Indian-language suite and Pakistan's regional languages.
Try it without MetaLink 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 itThe 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 dataYour 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:

Video quizzes on WhatsApp: tap-the-picture Flow, Urdu phonics by voice note, score card

-- 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} config.init + * @param {(ctx, screen, screenData) => Promise} config.exchange + * @param {Array<{screen: string, fields: Array<{id, optionsKey, prompt?}>}>} config.stages + * @param {(response, ctx) => string|null} [config.onFinish] message for the terminal response + * @param {string} [config.fallbackError] + * @returns {object} a text-flow.js definition + */ +function buildEndpointFlow(config) { + const { kind, init, exchange, stages, onFinish, fallbackError } = config; + if (!kind || typeof init !== 'function' || typeof exchange !== 'function' || !stages?.length) { + throw new Error('endpoint-text-flow: needs { kind, init, exchange, stages[] }'); + } + + const GENERIC_ERROR = fallbackError + || 'That is not available right now. Please try again later.'; + + // Flatten the stage/field tree into the flat step list text-flow.js drives, + // remembering for each step which stage it belongs to and whether it is that + // stage's first field (the only field that triggers an endpoint call). + const steps = []; + stages.forEach((stage, stageIndex) => { + stage.fields.forEach((field, fieldIndex) => { + steps.push({ + id: field.id, + /** + * A stage's FIRST field fetches: from INIT for the first stage, or by + * submitting the previous screen for any later one. Subsequent fields + * of the same stage read the response that fetch already produced — + * which is why the response is carried in `context` instead of being + * recomputed. Replaying data_exchange per render would re-run whatever + * side effects the endpoint has (student-videos' final screen *sends a + * video*), so replay is not merely wasteful, it is unsafe. + */ + async options(answers, context) { + if (fieldIndex > 0) { + return { options: rowsFrom(context.response, field.optionsKey), context }; + } + + const ctx = context._ctx || {}; + let response; + try { + response = stageIndex === 0 + ? await init(ctx) + : await exchange(ctx, stages[stageIndex - 1].screen, screenDataFrom(answers)); + } catch (error) { + logToFile('❌ endpoint-text-flow: endpoint threw', { + kind, screen: stage.screen, error: error.message, + }); + response = { data: { error: { message: GENERIC_ERROR } } }; + } + + return { + options: rowsFrom(response, field.optionsKey), + context: { ...context, response }, + }; + }, + async prompt(answers, context) { + const error = errorMessageOf(context.response); + if (error) return { body: error }; + const built = field.prompt ? await field.prompt(answers, context) : {}; + return built || {}; + }, + }); + }); + }); + + return { + kind, + steps, + /** Submits the last screen — the step that actually performs the action. */ + async onComplete(phone, answers, context) { + const ctx = context?._ctx || {}; + const lastScreen = stages[stages.length - 1].screen; + let response; + try { + response = await exchange(ctx, lastScreen, screenDataFrom(answers)); + } catch (error) { + logToFile('❌ endpoint-text-flow: final exchange threw', { + kind, screen: lastScreen, error: error.message, + }); + return { text: GENERIC_ERROR }; + } + + const error = errorMessageOf(response); + if (error) return { text: error }; + + const text = onFinish ? onFinish(response, ctx) : null; + return { text: text || null }; + }, + }; +} + +module.exports = { + buildEndpointFlow, + rowsFrom, + screenDataFrom, + errorMessageOf, +}; diff --git a/bot/shared/services/messaging/inbound/baileys-socket.adapter.js b/bot/shared/services/messaging/inbound/baileys-socket.adapter.js new file mode 100644 index 0000000..b2faa80 --- /dev/null +++ b/bot/shared/services/messaging/inbound/baileys-socket.adapter.js @@ -0,0 +1,535 @@ +/** + * Baileys inbound adapter — translates a Baileys `messages.upsert` event into + * the same Meta-webhook-shaped payload bot/whatsapp-bot.js's + * handleWebhookPost(req, res) already parses via + * shared/utils/validators.js#validateWebhookMessage. This is the "give + * Baileys a parallel entry path into the existing dispatch logic" piece from + * docs/onboarding/sandbox-production-design.md §1 — a bounded extraction, not + * a rewrite: nothing about handleWebhookPost's ~1000 lines of dispatch logic + * changes; this file only ever calls it with a synthetic {req, res} pair. + * + * Coverage: text, image, audio/voice, and document messages map cleanly onto + * Meta's shape and reach the real handlers (handleTextMessage, + * handleVoiceMessage, handleImageMessage, handleDocumentMessage). Meta-only + * interaction types (Flow submissions, interactive buttons/lists, carousel + * button replies) have no Baileys equivalent and are never synthesized here — + * those branches in handleWebhookPost simply never trigger under this + * driver, the same way they wouldn't for any WhatsApp client that doesn't + * support them. + */ + +const { logToFile } = require('../../../utils/logger'); +const connection = require('../baileys-connection'); +const baileysChannel = require('../baileys-channel.service'); +const pendingOptions = require('../pending-options'); +const textFlow = require('../text-flow'); + +// A stable, non-test, non-zero entry id — passes validators.isTestWebhook(). +const SYNTHETIC_ENTRY_ID = 'baileys-sandbox'; + +/** + * Extracts the bare phone number from a WhatsApp JID. + * + * Strips BOTH the @server suffix and any `:device` suffix. The device part is + * load-bearing, not theoretical: Baileys 7.x's lidMapping.getPNForLID() returns + * a device-scoped JID like `923001234567:0@s.whatsapp.net`. Keeping the `:0` + * corrupted everything downstream — it was stored as the user's phone_number, + * and baileys-channel.service.js's toJid() (which strips non-digits) turned + * `923001234567:0` into `9230012345670`, i.e. the real number with a trailing + * zero: a nonexistent destination that Baileys still reports as "sent". + */ +function jidToPhoneNumber(jid) { + return String(jid || '').split('@')[0].split(':')[0]; +} + +function isGroupOrStatusJid(jid) { + return typeof jid === 'string' && (jid.endsWith('@g.us') || jid === 'status@broadcast'); +} + +function isLidJid(jid) { + return typeof jid === 'string' && jid.endsWith('@lid'); +} + +// Process-local @lid -> phone-number cache, populated opportunistically from +// key.senderPn (which only some deliveries carry). +// +// Deliberately NOT persisted: Baileys 7.x owns LID<->phone mapping and writes +// its own lid-mapping-*.json files into the auth dir (a fresh pairing produced +// 700+), so resolveSenderPhoneNumberAsync() consults that authoritative store +// first. This map exists only to cover the gap before the native store has +// learned a mapping. An earlier hand-rolled lid-to-phone.json duplicated the +// library's own persistence and was removed once 7.x made it redundant. +const lidToPnCache = new Map(); + +/** + * WhatsApp's phone-number-privacy rollout can address a 1:1 chat via an + * opaque @lid JID instead of the sender's real phone-number JID. Treating the + * LID's numeric id as a phone number breaks BOTH the DB user identity (a bogus + * "phone number" is stored) and the reply itself — it goes to a JID that is + * not a real device, so Baileys reports "sent" and nothing is ever delivered. + * + * Resolution order, best source first: + * 1. Baileys' own LIDMappingStore (7.x) — the authoritative mapping, backed + * by the lid-mapping-*.json files the library persists itself. Async, so + * resolved via resolveSenderPhoneNumberAsync() on the dispatch path. + * 2. key.senderPn, harvested opportunistically into lidToPnCache — present + * on only some deliveries, which is why it alone was never reliable. + * 3. The raw LID digits, as a last resort so `from` is never empty. + * + * This sync variant covers 2 and 3; prefer the async one where possible. + */ +function resolveSenderPhoneNumber(waMessage) { + const jid = waMessage.key?.remoteJid; + if (!isLidJid(jid)) return jidToPhoneNumber(jid); + + rememberLidMapping(waMessage); + return lidToPnCache.get(jid) || jidToPhoneNumber(jid); +} + +/** + * Preferred resolver: consults Baileys' native LIDMappingStore first. + * + * Baileys 7.x owns LID↔phone mapping properly (sock.signalRepository + * .lidMapping, persisted as lid-mapping-*.json — a fresh pairing wrote 706 of + * them). 6.7.23 had no such store, which is why this adapter originally had to + * scrape key.senderPn and cache it by hand; that scraping is now only a + * fallback for whatever the store hasn't learned yet. + * + * @param {object} waMessage + * @param {object} [sock] the live socket; when absent, falls back to the sync path. + */ +async function resolveSenderPhoneNumberAsync(waMessage, sock) { + const jid = waMessage.key?.remoteJid; + if (!isLidJid(jid)) return jidToPhoneNumber(jid); + + rememberLidMapping(waMessage); + + const store = sock?.signalRepository?.lidMapping; + if (store?.getPNForLID) { + try { + const pnJid = await store.getPNForLID(jid); + const pn = jidToPhoneNumber(pnJid); + if (pn && pn !== jidToPhoneNumber(jid)) { + lidToPnCache.set(jid, pn); + return pn; + } + } catch (error) { + logToFile('⚠️ Baileys inbound: lidMapping.getPNForLID failed', { jid, error: error.message }); + } + } + + return lidToPnCache.get(jid) || jidToPhoneNumber(jid); +} + +/** + * Records this delivery's @lid -> real-phone-number mapping if it carries one. + * + * Called for EVERY delivery — including ones we then skip (failed-decryption + * stubs, our own echoes) — which is load-bearing, not defensive. A live test + * caught the interaction: after a restart the cache is empty, and the + * deliveries that DID carry sender_pn were exactly the ones that failed to + * decrypt, so gating this behind hasDispatchableContent() starved the cache. + * The successful retry then arrived with no senderPn, fell back to the LID, + * and the reply went to a JID that is not a real device — Baileys reported + * "sent" and the user received nothing. + */ +function rememberLidMapping(waMessage) { + const jid = waMessage.key?.remoteJid; + const senderPn = waMessage.key?.senderPn; + if (!isLidJid(jid) || !senderPn) return; + + const pn = jidToPhoneNumber(senderPn); + if (!pn || lidToPnCache.get(jid) === pn) return; + + lidToPnCache.set(jid, pn); +} + +/** Test-only: clears the @lid -> phone-number cache between test runs. */ +function _resetLidCacheForTests() { + lidToPnCache.clear(); +} + +/** + * Whether this message is one we'd actually dispatch — the SAME skip + * conditions mapToMetaShape applies, but computed synchronously and with no + * media download, so it can gate the dedup bookkeeping below. Kept as the one + * shared source of truth (mapToMetaShape defers to it) so the two can't drift. + * @param {import('baileys').WAMessage} waMessage + */ +function hasDispatchableContent(waMessage) { + const jid = waMessage.key?.remoteJid; + if (waMessage.key?.fromMe || isGroupOrStatusJid(jid) || !waMessage.message) return false; + + const content = waMessage.message; + return Boolean( + content.conversation + || content.extendedTextMessage?.text + || content.imageMessage + || content.audioMessage + || content.documentMessage + ); +} + +// Live testing found Baileys occasionally redelivers the identical message +// (same key.id) via messages.upsert within under a second — observed twice, +// for both an image and a document, causing each to be fully processed (and +// replied to) twice. The pre-existing Redis-backed dedup in +// session.service.js has an inherent network round-trip race window (a SET +// from the first delivery may not yet be visible to the second delivery's +// GET); this catches the exact same redelivery synchronously and in-memory, +// before either delivery ever reaches that network round trip. +// +// Only ever called for messages that already passed +// hasDispatchableContent() — a second live run caught why that ordering is +// load-bearing: Baileys' first delivery attempt of a message can FAIL TO +// DECRYPT ("No matching sessions"), arriving with no usable content, and it +// then retries the same key.id once decryption succeeds. Recording the id on +// that contentless first attempt made the real, decrypted retry look like a +// duplicate, so the message was dropped and never processed at all. +const SEEN_MESSAGE_TTL_MS = 5 * 60 * 1000; // generous vs. the <1s redeliveries observed live +const seenMessageIds = new Map(); // messageId -> firstSeenAt + +function isDuplicateDelivery(messageId) { + if (!messageId) return false; + const now = Date.now(); + for (const [id, seenAt] of seenMessageIds) { + if (now - seenAt > SEEN_MESSAGE_TTL_MS) seenMessageIds.delete(id); + } + if (seenMessageIds.has(messageId)) return true; + seenMessageIds.set(messageId, now); + return false; +} + +/** Test-only: clears the seen-message-id dedup cache between test runs. */ +function _resetSeenMessagesForTests() { + seenMessageIds.clear(); +} + +/** + * Turns a numeric reply to a pending numbered menu into the interactive payload + * Meta's native button/list picker would have sent, or null if this text isn't + * a menu selection. + * + * The shapes below mirror exactly what whatsapp-bot.js reads — + * `message.interactive.type` plus `.button_reply.id` / `.list_reply.id` (see + * its branches at the `messageType === 'interactive'` checks) — so no dispatch + * code has to change. The menu is cleared on a hit so the same "1" can't be + * replayed against a menu that has already been answered. + * + * @param {string} from bare phone number + * @param {string} text raw inbound text + * @returns {Promise} partial Meta message ({type, interactive}) or null + */ +async function toInteractiveSelection(from, text) { + const menu = await pendingOptions.get(from); + const selected = pendingOptions.resolveSelection(menu, text); + if (!selected) return null; + + await pendingOptions.clear(from); + logToFile('🔢 Baileys inbound: numeric reply resolved to an interactive selection', { + from, replyType: menu.replyType, id: selected.id, + }); + + const reply = { id: selected.id, title: selected.title }; + return menu.replyType === 'button_reply' + ? { type: 'interactive', interactive: { type: 'button_reply', button_reply: reply } } + : { type: 'interactive', interactive: { type: 'list_reply', list_reply: reply } }; +} + +/** + * Feeds text into the user's active text flow (the sandbox stand-in for a Meta + * Flow form — see text-flow.js). + * + * MUST run before toInteractiveSelection(): a text flow records its menu in the + * same pending-options store, so whichever runs first consumes the reply. + * + * @returns {Promise} + * null when there is no active flow (or the text didn't answer it), so the + * message continues to normal handling. {handled} when the flow consumed the + * reply and has already responded. {metaMessage} when the flow finished and + * produced a submission for the existing dispatch to route. + */ +/** + * Whether this message is a command in its own right, and so must never be + * consumed as an answer to a pending question. + * + * A "/" prefix is the easy case. The hard case is that several of this bot's + * commands are plain phrases — "add class", "set up class", "attendance", + * "حاضری" — and a FREE-TEXT step accepts literally any non-empty text. Live + * result: typing "add class" while a class-setup flow was waiting for the roster + * created a class whose only student was named "add class". + * + * The keyword lists are not duplicated here; the same detector the text handler + * routes on is asked, so the two cannot drift. + */ +function isCommandLike(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return false; + if (trimmed.startsWith('/')) return true; + + try { + // eslint-disable-next-line global-require -- keeps this adapter's load light + const detector = require('../../attendance-detector.service'); + if (detector.detectAddClassIntent(trimmed).detected) return true; + if (detector.detectAttendanceIntent(trimmed).detected) return true; + } catch (error) { + logToFile('⚠️ Baileys inbound: command detection unavailable', { error: error.message }); + } + return false; +} + +async function advanceActiveTextFlow(from, text) { + // A flow can outlive the process that started it (state is in Redis), so the + // definitions must be present before advancing, not only before starting. + // eslint-disable-next-line global-require -- lazy on purpose; see ensureRegistered() + require('../text-flow-definitions').ensureRegistered(); + + const result = await textFlow.advance(from, text); + if (!result) return null; + + // A step handled here never reaches handleWebhookPost, so without this line a + // mid-flow reply leaves no trace in the log at all — which makes a stuck flow + // impossible to diagnose. + logToFile('🧩 Baileys inbound: text-flow reply', { + from, status: result.status, step: result.render?.kind || null, + }); + + if (result.status === 'cancelled') { + await baileysChannel.sendMessage(from, 'Okay, cancelled. Type /menu to see what else I can do.'); + return { handled: true }; + } + + if (result.status === 'step') { + await baileysChannel._sendTextFlowStep(from, result.render); + return { handled: true }; + } + + // The flow ran out of options mid-way (the endpoint returned an error or no + // rows). render.prompt.body carries the endpoint's own explanation. + if (result.status === 'aborted') { + await baileysChannel._sendTextFlowStep(from, result.render); + return { handled: true }; + } + + if (result.status === 'complete') { + const outcome = await result.definition.onComplete(from, result.answers, result.context); + if (outcome?.text) await baileysChannel.sendMessage(from, outcome.text); + // A navigate-style Flow's submission: hand the synthesised nfm_reply to the + // normal dispatch so flow-response.handler.js runs exactly as it does on Meta. + if (outcome?.metaMessage) return { metaMessage: outcome.metaMessage }; + return { handled: true }; + } + + // 'unmatched' — the reply didn't answer the question. + // + // First strike: re-ask. Mid-flow, an unrecognised reply is usually a failed + // answer attempt, and quietly treating it as conversation leaves the user + // unsure whether the flow is still waiting. + // + // Second consecutive strike: give up the flow and let the message through. + // Not every command starts with "/" — "add class", "attendance" and + // "register" are plain-text triggers that mid-flow look exactly like a wrong + // answer, so an endlessly re-asking flow can trap the user in it. Two strikes + // self-heals without this file needing to know the command vocabulary. + if ((result.strikes || 0) >= 2) { + await textFlow.clear(from); + await pendingOptions.clear(from); + logToFile('⏹️ Baileys inbound: text flow abandoned after repeated unmatched replies', { from }); + return null; + } + + const state = await textFlow.getState(from); + if (state) { + const definition = textFlow.getDefinition(state.kind); + const render = definition + ? await textFlow.renderStep(from, definition, state.stepIndex, state.answers, state.context) + : null; + if (render) { + await baileysChannel.sendMessage( + from, + "Sorry, I didn't catch that. Please pick one of these — or reply *cancel* to stop." + ); + await baileysChannel._sendTextFlowStep(from, render); + return { handled: true }; + } + } + return null; +} + +/** + * @param {import('baileys').WAMessage} waMessage + * @param {(type: 'buffer', opts: object) => Promise} downloadMedia + * @returns {Promise<{ metaMessage: object, mediaToCache: {id: string, buffer: Buffer, mimetype: string} | null } | null>} + * null when the message should be skipped entirely (no content, group/status chat, echo of our own send). + */ +async function mapToMetaShape(waMessage, downloadMediaMessage, sock) { + if (!hasDispatchableContent(waMessage)) return null; + + const from = await resolveSenderPhoneNumberAsync(waMessage, sock); + const id = waMessage.key.id; + const timestamp = Number(waMessage.messageTimestamp) || Math.floor(Date.now() / 1000); + const base = { from, id, timestamp }; + + const content = waMessage.message; + + if (content.conversation || content.extendedTextMessage?.text) { + const text = content.conversation || content.extendedTextMessage.text; + + // A command always wins over an in-progress text flow, so a user can never + // get stuck: typing /menu (or "add class") mid-flow does what it says. The + // abandoned flow is discarded rather than left pending, or the NEXT reply + // (meant for whatever the command started) would be swallowed as its answer. + if (isCommandLike(text)) { + if (await textFlow.isActive(from)) { + await textFlow.clear(from); + await pendingOptions.clear(from); + logToFile('⏹️ Baileys inbound: text flow abandoned for a slash command', { from, text: text.trim() }); + } + } else { + const flowed = await advanceActiveTextFlow(from, text); + if (flowed?.metaMessage) { + return { metaMessage: { ...base, ...flowed.metaMessage }, mediaToCache: null }; + } + if (flowed?.handled) return null; + } + + // A bare number answering a menu this driver just rendered is really an + // interactive selection — synthesise the payload Meta's native button/list + // picker would have produced, so whatsapp-bot.js's existing + // `interactive.button_reply` / `list_reply` branches (33 ID families) handle + // it unchanged. Without this the number falls through to general AI chat and + // every numbered menu on this driver is unanswerable. + const interactive = await toInteractiveSelection(from, text); + if (interactive) return { metaMessage: { ...base, ...interactive }, mediaToCache: null }; + + return { metaMessage: { ...base, type: 'text', text: { body: text } }, mediaToCache: null }; + } + + if (content.imageMessage) { + const buffer = await downloadMediaMessage(waMessage, 'buffer', {}); + return { + metaMessage: { + ...base, + type: 'image', + image: { id, mime_type: content.imageMessage.mimetype, caption: content.imageMessage.caption || '' }, + }, + mediaToCache: { id, buffer, mimetype: content.imageMessage.mimetype }, + }; + } + + if (content.audioMessage) { + const buffer = await downloadMediaMessage(waMessage, 'buffer', {}); + const isVoiceNote = !!content.audioMessage.ptt; + return { + metaMessage: { ...base, type: isVoiceNote ? 'voice' : 'audio', audio: { id, mime_type: content.audioMessage.mimetype } }, + mediaToCache: { id, buffer, mimetype: content.audioMessage.mimetype }, + }; + } + + if (content.documentMessage) { + const buffer = await downloadMediaMessage(waMessage, 'buffer', {}); + return { + metaMessage: { + ...base, + type: 'document', + document: { + id, mime_type: content.documentMessage.mimetype, filename: content.documentMessage.fileName || 'document', + }, + }, + mediaToCache: { id, buffer, mimetype: content.documentMessage.mimetype }, + }; + } + + // Sticker, contact, location, reaction, poll, etc. — no Meta dispatch branch + // handles these types today either; matches the "Unsupported message type" + // catch-all handleWebhookPost already has. + return null; +} + +function buildSyntheticRequest(metaMessage) { + return { + body: { + entry: [{ + id: SYNTHETIC_ENTRY_ID, + changes: [{ + value: { + messages: [metaMessage], + metadata: {}, // no phone_number_id — validators.isOurPhoneNumber() auto-allows when absent + }, + }], + }], + }, + }; +} + +function buildSyntheticResponse() { + return { + status(code) { + return { send: (body) => logToFile('Baileys inbound: synthetic response', { code, body }) }; + }, + }; +} + +/** + * @param {(req: object, res: object) => Promise} dispatch handleWebhookPost from whatsapp-bot.js + */ +async function attach(dispatch) { + const { downloadMediaMessage } = await require('../baileys-lib').loadBaileys(); + const sock = await connection.getSocket(); + + sock.ev.on('messages.upsert', async ({ messages }) => { + for (const waMessage of messages) { + try { + // Harvest the @lid->phone mapping FIRST, from every delivery — the + // ones carrying senderPn are often exactly the undecryptable stubs + // skipped just below (see rememberLidMapping). + rememberLidMapping(waMessage); + + // Order matters — see the seenMessageIds comment above. Skip + // non-dispatchable deliveries (our own echoes, group chats, and + // crucially the not-yet-decrypted first attempts Baileys retries) + // WITHOUT recording the id, so a later decryptable retry of the same + // key.id is still processed. Both the check and the mark are + // synchronous with no await between them, so genuine concurrent + // redeliveries can't both slip through. + if (!hasDispatchableContent(waMessage)) continue; + + if (isDuplicateDelivery(waMessage.key?.id)) { + logToFile('⚠️ Baileys inbound: duplicate delivery skipped', { messageId: waMessage.key?.id }); + continue; + } + + const mapped = await mapToMetaShape(waMessage, downloadMediaMessage, sock); + if (!mapped) continue; + + if (mapped.mediaToCache) { + baileysChannel._cacheIncomingMedia(mapped.mediaToCache.id, mapped.mediaToCache.buffer, mapped.mediaToCache.mimetype); + } + + const req = buildSyntheticRequest(mapped.metaMessage); + const res = buildSyntheticResponse(); + await dispatch(req, res); + } catch (error) { + logToFile('❌ Baileys inbound: error processing message', { error: error.message, stack: error.stack }); + } + } + }); + + logToFile('✅ Baileys inbound listener attached', {}); +} + +module.exports = { + attach, + mapToMetaShape, + jidToPhoneNumber, + resolveSenderPhoneNumber, + resolveSenderPhoneNumberAsync, + rememberLidMapping, + hasDispatchableContent, + isDuplicateDelivery, + toInteractiveSelection, + advanceActiveTextFlow, + isCommandLike, + _resetLidCacheForTests, + _resetSeenMessagesForTests, +}; diff --git a/bot/shared/services/messaging/index.js b/bot/shared/services/messaging/index.js new file mode 100644 index 0000000..fac11e8 --- /dev/null +++ b/bot/shared/services/messaging/index.js @@ -0,0 +1,31 @@ +/** + * messaging/index.js — channel driver selector. + * + * CHANNEL_DRIVER=meta → Meta WhatsApp Cloud API (needs WHATSAPP_TOKEN + PHONE_NUMBER_ID + ...) + * CHANNEL_DRIVER=baileys → (default) sandbox WhatsApp Web driver, no Meta account needed + * + * Every driver exposes the identical method surface the rest of the bot + * already depends on (see bot/shared/services/whatsapp.service.js, now a thin + * facade over this file) — mirrors the shared/services/queue/index.js + * driver-selector pattern. Unknown/unset CHANNEL_DRIVER resolution (including + * backward-compat inference for pre-existing Meta deployments) lives in + * feature-availability.js's resolveChannelDriver so the config file stays the + * single source of truth for that decision; this file just requires the + * result and logs when the raw env value didn't name a real driver. + */ + +const { DRIVERS, DEFAULT_DRIVER } = require('./channel-registry'); +const { resolveChannelDriver } = require('../../config/feature-availability'); +const { logToFile } = require('../../utils/logger'); + +const rawDriver = (process.env.CHANNEL_DRIVER || '').trim().toLowerCase(); +const driverName = resolveChannelDriver(process.env); + +if (rawDriver && !Object.prototype.hasOwnProperty.call(DRIVERS, rawDriver)) { + logToFile( + `⚠️ Unknown CHANNEL_DRIVER="${rawDriver}" — falling back to ${driverName}. Valid values: ${Object.keys(DRIVERS).join(' | ')}.`, + { level: 'warn' } + ); +} + +module.exports = require(DRIVERS[driverName]); diff --git a/bot/shared/services/messaging/meta-channel.service.js b/bot/shared/services/messaging/meta-channel.service.js new file mode 100644 index 0000000..2a73fc5 --- /dev/null +++ b/bot/shared/services/messaging/meta-channel.service.js @@ -0,0 +1,1936 @@ +/** + * Meta WhatsApp Cloud API channel driver — the production-tier driver. + * + * This is a MECHANICAL LIFT of bot/shared/services/whatsapp.service.js (which + * is now a thin facade over messaging/index.js). The only intentional edits are + * require-path depth fixes, since this file sits one directory deeper than the + * original. + * + * MAINTENANCE: because it is a copy, it goes stale if the upstream service + * changes. That already bit once — this branch was cut before + * `feat(languages): add Indian-language support` landed on main, so the first + * lift carried a hardcoded 10-language list while main had moved to the shared + * config/supported-languages.js with region filtering. Re-lift from the CURRENT + * whatsapp.service.js when rebasing, and let + * tests/messaging/channel-driver-parity.test.js + the require-time assertion in + * baileys-channel.service.js catch any method the Baileys driver hasn't caught + * up with. + */ +const axios = require('axios'); +const FormData = require('form-data'); +const fs = require('fs'); +const { WHATSAPP_TOKEN, PHONE_NUMBER_ID } = require('../../utils/constants'); +const { logToFile } = require('../../utils/logger'); +const { downloadFromR2, extractKeyFromUrl } = require('../../storage/r2'); + +// Prefer ASSET_BASE_URL; fall back to legacy ASSETS_BASE_URL. Empty when +// neither is set — the carousel template builder below guards against that. +const ASSETS_BASE_URL = (process.env.ASSET_BASE_URL || process.env.ASSETS_BASE_URL || '').replace(/\/$/, ''); +const GRAPH_API_VERSION = process.env.GRAPH_API_VERSION || 'v21.0'; +const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`; + +/** + * WhatsApp Service + * Handles all WhatsApp Cloud API interactions + */ +class WhatsAppService { + /** + * Remove emotion tags from text + * @param {string} text - Text that may contain emotion tags like [warmly], [thoughtfully], etc. + * @returns {string} Text with emotion tags removed + * @private + */ + static _removeEmotionTags(text) { + // Remove emotion tags like [warmly], [thoughtfully], [enthusiastically], etc. + // Also handles tags with spaces inside like [warm ly] + return text.replace(/\[[a-zA-Z\s]+\]\s*/g, '').trim(); + } + + /** + * Send a text message via WhatsApp + * @param {string} to - Recipient phone number + * @param {string} message - Message text + * @returns {Promise} + */ + static async sendMessage(to, message) { + try { + // Remove emotion tags from text messages (they're only for voice) + const cleanMessage = this._removeEmotionTags(message); + + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + to: to, + type: 'text', + text: { body: cleanMessage }, + }), + } + ); + + const data = await response.json(); + if (!response.ok) { + logToFile('❌ Error sending WhatsApp message', { responseData: data }); + return false; + } + logToFile('✅ WhatsApp message sent', { messageId: data?.messages?.[0]?.id }); + return true; + } catch (error) { + logToFile('❌ Exception sending WhatsApp message', { error: error.message }); + return false; + } + } + + /** + * Send text and RETURN THE MESSAGE ID, optionally as a quoted reply. + * + * Every other send helper here returns a boolean, which is fine when nothing + * needs to refer back to the message. The video quiz does: an audio option's + * label is sent as a quoted reply to the clip it names (Meta: + * context.message_id), because otherwise a column of near-identical voice + * notes and a column of labels are related only by luck — a child cannot tell + * which "Sound 2" belongs to which recording. That needs the id of the + * message we just sent. + * + * Additive: no existing caller is affected. + * + * @param {string} to + * @param {string} message + * @param {Object} [opts] { contextMessageId } + * @returns {Promise} the sent message's id, or null on failure + */ + static async sendTextReturningId(to, message, opts = {}) { + try { + const cleanMessage = this._removeEmotionTags(message); + const payload = { + messaging_product: 'whatsapp', + to, + type: 'text', + text: { body: cleanMessage }, + }; + if (opts.contextMessageId) { + payload.context = { message_id: opts.contextMessageId }; + } + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + const data = await response.json(); + if (!response.ok) { + logToFile('❌ Error sending WhatsApp message (returning id)', { responseData: data }); + return null; + } + return data?.messages?.[0]?.id || null; + } catch (error) { + logToFile('❌ Exception sending WhatsApp message (returning id)', { error: error.message }); + return null; + } + } + + /** + * Send audio from a URL and return the message id. + * Same reason as sendTextReturningId: the option label must quote this clip. + */ + static async sendAudioFromUrlReturningId(to, audioUrl) { + try { + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', to, type: 'audio', + audio: { link: audioUrl }, + }), + } + ); + const data = await response.json(); + if (!response.ok) { + logToFile('❌ Error sending WhatsApp audio (returning id)', { responseData: data }); + return null; + } + return data?.messages?.[0]?.id || null; + } catch (error) { + logToFile('❌ Exception sending WhatsApp audio (returning id)', { error: error.message }); + return null; + } + } + + /** + * Send a reaction to a message + * @param {string} to - Recipient phone number + * @param {string} messageId - Message ID to react to + * @param {string} emoji - Emoji to send (default: ❤️) + * @returns {Promise} + */ + static async sendReaction(to, messageId, emoji = '❤️') { + try { + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'reaction', + reaction: { + message_id: messageId, + emoji: emoji, + }, + }), + } + ); + + const data = await response.json(); + if (!response.ok) { + logToFile('Error sending reaction', data); + return false; + } + logToFile('Reaction sent successfully', { emoji, messageId }); + return true; + } catch (error) { + logToFile('Error sending reaction', { error: error.message }); + return false; + } + } + + /** + * Show typing indicator and mark message as read + * @param {string} to - Recipient phone number + * @param {string} messageId - Message ID + * @returns {Promise} + */ + static async showTypingIndicator(to, messageId) { + try { + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + status: 'read', + message_id: messageId, + typing_indicator: { + type: 'text', + }, + }), + } + ); + + const data = await response.json(); + if (!response.ok) { + logToFile('Error showing typing indicator', data); + return false; + } + logToFile('Typing indicator shown'); + return true; + } catch (error) { + logToFile('Error showing typing indicator', { error: error.message }); + return false; + } + } + + /** + * Start continuous typing indicator that lasts until response is sent + * The typing indicator will be refreshed every 20 seconds to keep it active + * @param {string} to - Recipient phone number + * @param {string} messageId - Message ID + * @returns {Object} Controller object with stop() method to stop the typing indicator + */ + static startContinuousTypingIndicator(to, messageId) { + // Show typing indicator immediately + this.showTypingIndicator(to, messageId); + + // Refresh typing indicator every 20 seconds (before the 25 second timeout) + const intervalId = setInterval(() => { + this.showTypingIndicator(to, messageId); + }, 20000); // 20 seconds + + // Return a controller object to stop the typing indicator + return { + stop: () => { + clearInterval(intervalId); + logToFile('Continuous typing indicator stopped'); + } + }; + } + + /** + * Get media metadata (including duration for audio/video files) + * @param {string} mediaId - Media ID from WhatsApp + * @returns {Promise} Media metadata including url, mime_type, size, and duration (for audio/video) + */ + static async getMediaInfo(mediaId) { + try { + const mediaUrlResponse = await axios.get( + `${GRAPH_API_BASE}/${mediaId}`, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + }, + } + ); + + return mediaUrlResponse.data; + } catch (error) { + logToFile('❌ Error getting WhatsApp media info', { error: error.message }); + throw error; + } + } + + /** + * Download media from WhatsApp + * @param {string} mediaId - Media ID from WhatsApp + * @returns {Promise} + */ + static async downloadMedia(mediaId) { + try { + // Get media URL + const mediaInfo = await this.getMediaInfo(mediaId); + const mediaUrl = mediaInfo.url; + + // Download media file + const mediaResponse = await axios.get(mediaUrl, { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + }, + responseType: 'arraybuffer', + }); + + return Buffer.from(mediaResponse.data); + } catch (error) { + logToFile('❌ Error downloading WhatsApp media', { error: error.message }); + throw error; + } + } + + /** + * Send a document via WhatsApp + * @param {string} to - Recipient phone number + * @param {string} filePath - Path to the document file + * @param {string} filename - Filename to display + * @param {string} caption - Document caption + * @returns {Promise} + */ + static async sendDocument(to, filePath, filename, caption) { + try { + // Determine MIME type based on file extension + const ext = filename.toLowerCase().split('.').pop(); + const mimeTypes = { + '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' + }; + const contentType = mimeTypes[ext] || 'application/octet-stream'; + + // Upload document to WhatsApp + const formData = new FormData(); + formData.append('file', fs.createReadStream(filePath), { + contentType: contentType, + filename: filename, + }); + formData.append('messaging_product', 'whatsapp'); + + const uploadResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, + formData, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + ...formData.getHeaders(), + }, + } + ); + + const mediaId = uploadResponse.data.id; + + // Send document message + const sendResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + messaging_product: 'whatsapp', + to: to, + type: 'document', + document: { + id: mediaId, + caption: caption, + filename: filename + }, + }, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('Document sent successfully', { response: sendResponse.data }); + return true; + } catch (error) { + logToFile('Error sending document', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send an audio message via WhatsApp + * @param {string} to - Recipient phone number + * @param {Buffer} audioBuffer - Audio file buffer + * @param {string} tempDir - Temporary directory for files + * @returns {Promise} + */ + static async sendAudio(to, audioBuffer, tempDir) { + const path = require('path'); + + try { + // Save audio to temp file + const audioPath = path.join(tempDir, `audio_${Date.now()}.mp3`); + fs.writeFileSync(audioPath, audioBuffer); + + // Upload media to WhatsApp + const formData = new FormData(); + formData.append('file', fs.createReadStream(audioPath), { + contentType: 'audio/mpeg', + filename: 'audio.mp3', + }); + formData.append('messaging_product', 'whatsapp'); + + const uploadResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, + formData, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + ...formData.getHeaders(), + }, + } + ); + + const mediaId = uploadResponse.data.id; + + // Send audio message + const sendResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + messaging_product: 'whatsapp', + to: to, + type: 'audio', + audio: { + id: mediaId, + }, + }, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + // Clean up temp file + fs.unlinkSync(audioPath); + + logToFile('Audio message sent successfully', { response: sendResponse.data }); + return true; + } catch (error) { + logToFile('❌ Error sending audio message', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send a document from URL via WhatsApp + * @param {string} to - Recipient phone number + * @param {string} documentUrl - URL of the document in R2 storage + * @param {string} filename - Filename for the document + * @param {string} caption - Optional caption + * @returns {Promise} + */ + static async sendDocumentFromUrl(to, documentUrl, filename, caption) { + const path = require('path'); + const tempDir = path.join(__dirname, '../../../temp'); + + try { + // Extract R2 key from URL and download using R2 client + logToFile('Downloading document from R2', { documentUrl }); + const key = extractKeyFromUrl(documentUrl); + const documentBuffer = await downloadFromR2(key); + + // Save to temp file + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + const tempFilePath = path.join(tempDir, `temp_${Date.now()}_${filename}`); + fs.writeFileSync(tempFilePath, documentBuffer); + + logToFile('Document downloaded from R2, sending to WhatsApp', { tempFilePath, size: documentBuffer.length }); + + // Use existing sendDocument method + const result = await this.sendDocument(to, tempFilePath, filename, caption); + + // Clean up temp file + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + return result; + } catch (error) { + logToFile('❌ Error sending document from URL', { + error: error.message, + documentUrl, + stack: error.stack + }); + return false; + } + } + + /** + * Send audio from URL via WhatsApp + * @param {string} to - Recipient phone number + * @param {string} audioUrl - URL of the audio file in R2 storage + * @returns {Promise} + */ + static async sendAudioFromUrl(to, audioUrl) { + const path = require('path'); + const tempDir = path.join(__dirname, '../../../temp'); + + try { + // Extract R2 key from URL and download using R2 client + logToFile('Downloading audio from R2', { audioUrl }); + const key = extractKeyFromUrl(audioUrl); + const audioBuffer = await downloadFromR2(key); + + logToFile('Audio downloaded from R2, sending to WhatsApp', { audioSize: audioBuffer.length }); + + // Use existing sendAudio method + return await this.sendAudio(to, audioBuffer, tempDir); + } catch (error) { + logToFile('❌ Error sending audio from URL', { + error: error.message, + audioUrl, + stack: error.stack + }); + return false; + } + } + + /** + * Send an image from a (typically R2) URL via WhatsApp. + * R2 URLs are private — WhatsApp can't fetch them directly (see the R2 note + * in sendImageWithButtons) — so we download the bytes and hand the temp file + * to sendImage, which uploads it to the Media API. Mirrors sendDocumentFromUrl + * and sendAudioFromUrl. + * @param {string} to - Recipient phone number + * @param {string} imageUrl - URL of the image in R2 storage + * @param {string} caption - Optional caption + * @returns {Promise} + */ + static async sendImageFromUrl(to, imageUrl, caption = '') { + const path = require('path'); + const tempDir = path.join(__dirname, '../../../temp'); + + try { + // Extract R2 key from URL and download using R2 client + logToFile('Downloading image from R2', { imageUrl }); + const key = extractKeyFromUrl(imageUrl); + const imageBuffer = await downloadFromR2(key); + + // Save to temp file + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + const tempFilePath = path.join(tempDir, `img_${Date.now()}.png`); + fs.writeFileSync(tempFilePath, imageBuffer); + + logToFile('Image downloaded from R2, sending to WhatsApp', { tempFilePath, size: imageBuffer.length }); + + // tempFilePath contains '/' so sendImage takes the upload-file branch. + const result = await this.sendImage(to, tempFilePath, caption); + + // Clean up temp file + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + return result; + } catch (error) { + logToFile('❌ Error sending image from URL', { + error: error.message, + imageUrl, + stack: error.stack + }); + return false; + } + } + + /** + * Send an approved WhatsApp template message. + * Used for paid utility/marketing sends outside the 24h customer-service + * window (e.g. the quiz invite to cold parents). The template must already be + * approved in the WABA — a clone without it registered gets a clear Meta + * "template not found" error logged here and a false return (the caller + * continues); it is a deployment-config gap, not a code bug. + * @param {string} to - Recipient phone number + * @param {string} templateName - Approved template name + * @param {string} languageCode - Template language code (e.g. 'en', 'ur') + * @param {Array} components - Template components (header/body/button params) + * @returns {Promise} + */ + static async sendTemplate(to, templateName, languageCode, components = []) { + try { + const payload = { + messaging_product: 'whatsapp', + to, + type: 'template', + template: { + name: templateName, + language: { code: languageCode }, + ...(components && components.length ? { components } : {}), + }, + }; + + const response = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + payload, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('✅ Template message sent', { + to: to.slice(-4), + templateName, + languageCode, + response: response.data, + }); + return true; + } catch (error) { + logToFile('❌ Error sending template message', { + error: error.message, + errorDetails: error.response?.data, + templateName, + languageCode, + }); + return false; + } + } + + /** + * Send a video message via WhatsApp + * @param {string} to - Recipient phone number + * @param {Buffer} videoBuffer - Video file buffer + * @param {string} tempDir - Temporary directory for files + * @param {string} caption - Optional caption for the video + * @returns {Promise} + */ + static async sendVideo(to, videoBuffer, tempDir, caption = '') { + const path = require('path'); + + try { + // Save video to temp file + const videoPath = path.join(tempDir, `video_${Date.now()}.mp4`); + fs.writeFileSync(videoPath, videoBuffer); + + logToFile('Uploading video to WhatsApp', { size: videoBuffer.length, path: videoPath }); + + // Upload media to WhatsApp + const formData = new FormData(); + formData.append('file', fs.createReadStream(videoPath), { + contentType: 'video/mp4', + filename: 'video.mp4', + }); + formData.append('messaging_product', 'whatsapp'); + + const uploadResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, + formData, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + ...formData.getHeaders(), + }, + } + ); + + const mediaId = uploadResponse.data.id; + logToFile('Video uploaded to WhatsApp', { mediaId }); + + // Send video message + const messagePayload = { + messaging_product: 'whatsapp', + to: to, + type: 'video', + video: { + id: mediaId, + }, + }; + + // Add caption if provided + if (caption) { + messagePayload.video.caption = caption; + } + + const sendResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + messagePayload, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + // Clean up temp file + fs.unlinkSync(videoPath); + + logToFile('✅ Video message sent successfully', { response: sendResponse.data }); + return true; + } catch (error) { + logToFile('❌ Error sending video message', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send video from URL via WhatsApp (downloads from R2 first) + * @param {string} to - Recipient phone number + * @param {string} videoUrl - URL of the video file in R2 storage + * @param {string} caption - Optional caption for the video + * @returns {Promise} + */ + static async sendVideoFromUrl(to, videoUrl, caption = '') { + const path = require('path'); + const tempDir = path.join(__dirname, '../../../temp'); + + try { + // Extract R2 key from URL and download using R2 client + logToFile('📹 Downloading video from R2', { videoUrl }); + const key = extractKeyFromUrl(videoUrl); + const videoBuffer = await downloadFromR2(key); + + logToFile('Video downloaded from R2, sending to WhatsApp', { videoSize: videoBuffer.length }); + + // Use existing sendVideo method + return await this.sendVideo(to, videoBuffer, tempDir, caption); + } catch (error) { + logToFile('❌ Error sending video from URL', { + error: error.message, + videoUrl, + stack: error.stack + }); + return false; + } + } + + /** + * Send an image via WhatsApp + * @param {string} to - Recipient phone number + * @param {string} mediaIdOrPath - Either a WhatsApp media ID or path to image file + * @param {string} caption - Optional caption + * @returns {Promise} + */ + static async sendImage(to, mediaIdOrPath, caption = '') { + const path = require('path'); + + try { + let mediaId; + + // Check if mediaIdOrPath is a file path or media ID + // Media IDs are numeric strings, file paths contain slashes or backslashes + const isFilePath = mediaIdOrPath.includes('/') || mediaIdOrPath.includes('\\'); + + if (isFilePath) { + // Upload image to WhatsApp + logToFile('Uploading image from file', { path: mediaIdOrPath }); + const formData = new FormData(); + const ext = path.extname(mediaIdOrPath).toLowerCase(); + const contentType = ext === '.png' ? 'image/png' : 'image/jpeg'; + + formData.append('file', fs.createReadStream(mediaIdOrPath), { + contentType: contentType, + filename: path.basename(mediaIdOrPath), + }); + formData.append('messaging_product', 'whatsapp'); + + const uploadResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, + formData, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + ...formData.getHeaders(), + }, + } + ); + + mediaId = uploadResponse.data.id; + logToFile('Image uploaded to WhatsApp', { mediaId }); + } else { + // Use provided media ID + mediaId = mediaIdOrPath; + logToFile('Using cached image media ID', { mediaId }); + } + + // Send image message + const sendResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + messaging_product: 'whatsapp', + to: to, + type: 'image', + image: { + id: mediaId, + caption: caption, + }, + }, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('Image sent successfully', { response: sendResponse.data }); + return true; + } catch (error) { + logToFile('❌ Error sending image', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send an animated sticker via WhatsApp + * @param {string} to - Recipient phone number + * @param {string} mediaIdOrPath - Either a WhatsApp media ID or path to WebP sticker file + * @returns {Promise} + */ + static async sendSticker(to, mediaIdOrPath) { + const path = require('path'); + + try { + let mediaId; + + // Check if mediaIdOrPath is a file path or media ID + // Media IDs are numeric strings, file paths contain slashes or backslashes + const isFilePath = mediaIdOrPath.includes('/') || mediaIdOrPath.includes('\\'); + + if (isFilePath) { + // Stickers are optional. The repo ships `bot/marketing/` with a README + // but no binary assets — the cloner brings their own (or skips the + // feature). If the file isn't there, log once and return false so the + // caller can move on without crashing the bot. + if (!fs.existsSync(mediaIdOrPath)) { + logToFile('Sticker file not found — skipping sticker send (cosmetic)', { + path: mediaIdOrPath, + hint: 'Add a WebP sticker at this path, or set LOADING_STICKER_MEDIA_ID in .env to use a pre-uploaded Meta media ID.', + }); + return false; + } + + // Upload WebP sticker to WhatsApp + logToFile('Uploading sticker from file', { path: mediaIdOrPath }); + const formData = new FormData(); + + formData.append('file', fs.createReadStream(mediaIdOrPath), { + contentType: 'image/webp', + filename: path.basename(mediaIdOrPath), + }); + formData.append('messaging_product', 'whatsapp'); + + const uploadResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, + formData, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + ...formData.getHeaders(), + }, + } + ); + + mediaId = uploadResponse.data.id; + logToFile('Sticker uploaded to WhatsApp', { mediaId }); + } else { + // Use provided media ID + mediaId = mediaIdOrPath; + logToFile('Using cached sticker media ID', { mediaId }); + } + + // Send sticker message + const sendResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'sticker', + sticker: { + id: mediaId + } + }, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('Sticker sent successfully', { response: sendResponse.data }); + return true; + } catch (error) { + logToFile('❌ Error sending sticker', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send an interactive button message via WhatsApp + * @param {string} to - Recipient phone number + * @param {Object} options - Button message options + * @param {string} options.body - Message body text + * @param {Array<{id: string, title: string}>} options.buttons - Array of buttons (max 3) + * @returns {Promise} + */ + static async sendInteractiveButtons(to, options) { + try { + const { body, buttons } = options; + + // WhatsApp allows max 3 buttons + if (buttons.length > 3) { + logToFile('⚠️ Too many buttons, WhatsApp allows max 3', { count: buttons.length }); + return false; + } + + // Format buttons for WhatsApp API + const formattedButtons = buttons.map(btn => ({ + type: 'reply', + reply: { + id: btn.id, + title: btn.title.substring(0, 20) // WhatsApp button title max 20 chars + } + })); + + const response = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'button', + body: { + text: body + }, + action: { + buttons: formattedButtons + } + } + }, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('Interactive button message sent successfully', { response: response.data }); + return true; + } catch (error) { + logToFile('❌ Error sending interactive button message', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send image with interactive reply buttons (for vocabulary questions) + * Word-level comprehension assessment + * Fixed R2 private URL issue - now downloads from R2 first, uploads to WhatsApp + * @param {string} to - Recipient phone number + * @param {string} imageUrl - URL of image (R2 or public URL) + * @param {string} bodyText - Question text (e.g., "Which picture shows 'tree'?") + * @param {Array<{id: string, title: string}>} buttons - Array of buttons (max 3) + * @returns {Promise} + */ + static async sendImageWithButtons(to, imageUrl, bodyText, buttons) { + const path = require('path'); + const tempDir = path.join(__dirname, '../../../temp'); + + try { + // WhatsApp allows max 3 buttons + if (buttons.length > 3) { + logToFile('⚠️ Too many buttons for image message, WhatsApp allows max 3', { count: buttons.length }); + return false; + } + + // Format buttons for WhatsApp API + const formattedButtons = buttons.map(btn => ({ + type: 'reply', + reply: { + id: btn.id, + title: btn.title.substring(0, 20) // WhatsApp button title max 20 chars + } + })); + + // Check if this is an R2 URL (private endpoint) + // R2 URLs contain "r2.cloudflarestorage.com" - WhatsApp can't download from these + // We need to download first, then upload to WhatsApp to get a media_id + const isR2Url = imageUrl.includes('r2.cloudflarestorage.com'); + let imageHeader; + + if (isR2Url) { + logToFile('📥 Downloading image from R2 (private URL)', { imageUrl }); + + // Extract R2 key and download using credentials + const key = extractKeyFromUrl(imageUrl); + const imageBuffer = await downloadFromR2(key); + + // Save to temp file + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + const tempFilePath = path.join(tempDir, `vocab_${Date.now()}.png`); + fs.writeFileSync(tempFilePath, imageBuffer); + + logToFile('📤 Uploading image to WhatsApp Media API', { size: imageBuffer.length }); + + // Upload to WhatsApp Media API + const formData = new FormData(); + formData.append('file', fs.createReadStream(tempFilePath), { + contentType: 'image/png', + filename: 'vocabulary.png', + }); + formData.append('messaging_product', 'whatsapp'); + + const uploadResponse = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, + formData, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + ...formData.getHeaders(), + }, + } + ); + + const mediaId = uploadResponse.data.id; + logToFile('✅ Image uploaded to WhatsApp', { mediaId }); + + // Clean up temp file + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + // Use media ID instead of link + imageHeader = { id: mediaId }; + } else { + // Public URL - WhatsApp can download directly + imageHeader = { link: imageUrl }; + } + + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'button', + header: { + type: 'image', + image: imageHeader + }, + body: { text: bodyText }, + action: { + buttons: formattedButtons + } + } + }; + + const response = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + payload, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('✅ Image with buttons sent successfully', { + response: response.data, + imageUrl, + usedMediaId: isR2Url, + buttonCount: buttons.length + }); + return true; + } catch (error) { + logToFile('❌ Error sending image with buttons', { + error: error.message, + errorDetails: error.response?.data, + imageUrl + }); + return false; + } + } + + /** + * Send interactive list message (used for Reading Assessment) + * Supports WhatsApp Interactive Lists with sections and rows + * @param {string} to - WhatsApp phone number (with country code) + * @param {object} listData - List configuration object + * @returns {Promise} Success status + */ + static async sendInteractiveMessage(to, listData) { + try { + // Extract from nested structure (reading-assessment.service.js passes action.sections) + const { header, body, footer, action } = listData; + const { button, sections } = action || {}; + + // Validate sections (WhatsApp allows max 10 sections, max 10 total rows) + if (!sections || sections.length === 0) { + logToFile('⚠️ No sections provided for interactive list', { listData }); + return false; + } + + if (sections.length > 10) { + logToFile('⚠️ Too many sections, WhatsApp allows max 10', { count: sections.length }); + return false; + } + + // Count total rows across all sections + const totalRows = sections.reduce((sum, section) => sum + (section.rows?.length || 0), 0); + if (totalRows > 10) { + logToFile('⚠️ Too many rows, WhatsApp allows max 10 total', { count: totalRows }); + return false; + } + + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'list', + body: { + text: body.text || body // Support both {text: '...'} and direct string + }, + action: { + button: button || 'Options', + sections: sections + } + } + }; + + // Add optional header and footer + if (header) { + payload.interactive.header = { + type: header.type || 'text', + text: header.text || header // Support both {type: 'text', text: '...'} and direct string + }; + } + + if (footer) { + payload.interactive.footer = { + text: footer.text || footer // Support both {text: '...'} and direct string + }; + } + + const response = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + payload, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('✅ Interactive list message sent successfully', { response: response.data }); + return true; + } catch (error) { + logToFile('❌ Error sending interactive list message', { + error: error.message, + errorDetails: error.response?.data + }); + return false; + } + } + + /** + * Send a WhatsApp Flow + * @param {string} to - Recipient phone number + * @param {object} flowData - Flow configuration + * @param {string} flowData.flowId - Flow ID (e.g., '819028084215847') + * @param {string} flowData.header - Header text + * @param {string} flowData.body - Body text + * @param {string} flowData.footer - Footer text (optional) + * @param {string} flowData.buttonText - CTA button text (default: 'Start') + * @param {string} flowData.screen - Initial screen to navigate to (default: 'READING_ASSESSMENT') + * @param {string} flowData.flowToken - Custom flow token for data endpoint (optional, auto-generated if not provided) + * @returns {Promise} Success status + */ + static async sendFlow(to, flowData) { + try { + const { flowId, header, body, footer, buttonText = 'Start', screen, flowToken } = flowData; + + if (!flowId) { + logToFile('❌ Flow ID is required', { flowData }); + return false; + } + + // Determine flow action mode: + // - If screen is specified: use 'navigate' with flow_action_payload.screen (static flows) + // - If no screen but flowToken exists: use 'data_exchange' (endpoint-based flows with data_api_version 3.0+) + const useDataExchange = !screen && flowToken; + const flowAction = useDataExchange ? 'data_exchange' : 'navigate'; + + const parameters = { + flow_message_version: '3', + flow_token: flowToken || `flow_${Date.now()}`, + flow_id: flowId, + flow_cta: buttonText, + flow_action: flowAction + }; + + // Only add flow_action_payload with screen for navigate mode + if (!useDataExchange) { + parameters.flow_action_payload = { + screen: screen || 'READING_ASSESSMENT' // Default for backward compatibility + }; + } + + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'flow', + header: header ? { type: 'text', text: header } : undefined, + body: { text: body }, + footer: footer ? { text: footer } : undefined, + action: { + name: 'flow', + parameters: parameters + } + } + }; + + // Remove undefined fields + if (!payload.interactive.header) delete payload.interactive.header; + if (!payload.interactive.footer) delete payload.interactive.footer; + + logToFile('📤 Sending WhatsApp Flow', { + to, + flowId, + header, + body, + hasCustomFlowToken: !!flowToken + }); + + const response = await axios.post( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + payload, + { + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + logToFile('✅ WhatsApp Flow sent successfully', { + response: response.data, + flowId + }); + + return true; + } catch (error) { + logToFile('❌ Error sending WhatsApp Flow', { + error: error.message, + errorDetails: error.response?.data, + flowData + }); + return false; + } + } + + /** + * Send language selection interactive list + * Allows users to choose their preferred language via /language command + * + * @param {string} to - Recipient phone number + * @param {string} currentLanguage - User's current language for bilingual header + * @returns {Promise} + */ + static async sendLanguageSelectionList(to, currentLanguage = 'en', region = null) { + try { + logToFile('Sending language selection list', { to, currentLanguage, region }); + + const { LANGUAGES, SUPPORTED_LANGUAGES } = require('../../config/supported-languages'); + + // Resolve which languages to offer from the region's config (fail-open). + // Single-deployment/per-user-region: India users see Indian languages, + // everyone else sees the default set. WhatsApp interactive lists allow at + // most 10 rows total; we reserve one for Auto-detect, so cap codes at 9. + const DEFAULT_PICKER_CODES = ['en', 'ur', 'pa-PK', 'sd-PK', 'ps-PK', 'bal-PK', 'ta-LK', 'ar', 'es']; + let codes = DEFAULT_PICKER_CODES; + try { + 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)) + : []; + // Use the region's list only if it is more specific than the trivial + // ['en'] fail-open default; otherwise keep the full default picker set. + if (fromRegion.length > 1) codes = fromRegion; + } catch (e) { + logToFile('Language picker: region lookup failed, using default set', { error: e.message }); + } + + const MAX_LANG_ROWS = 9; // 10 total minus the Auto-detect row + if (codes.length > MAX_LANG_ROWS) { + logToFile('Language picker: truncating to WhatsApp 10-row limit', { region, shown: MAX_LANG_ROWS, total: codes.length }); + codes = codes.slice(0, MAX_LANG_ROWS); + } + + const languageRows = [ + { id: 'lang_auto', title: 'Auto-detect', description: 'Let me detect your language automatically' }, + ...codes.map((code) => ({ + id: `lang_${code}`, + title: (LANGUAGES[code]?.native || code).slice(0, 24), + description: `${LANGUAGES[code]?.english || code} language`.slice(0, 72), + })), + ]; + + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'list', + header: { + type: 'text', + text: 'Select Language' + }, + body: { + text: 'Choose your preferred language. I will respond in this language for all conversations.' + }, + footer: { + text: 'You can change this anytime by typing /language' + }, + action: { + button: 'Languages', + sections: [ + { + title: 'Available Languages', + rows: languageRows + } + ] + } + } + }), + } + ); + + const data = await response.json(); + if (!response.ok) { + logToFile('❌ Error sending language selection list', { error: data }); + return false; + } + + logToFile('✅ Language selection list sent successfully', { messageId: data.messages?.[0]?.id }); + return true; + } catch (error) { + logToFile('❌ Error sending language selection list', { + error: error.message + }); + return false; + } + } + + /** + * Build style carousel payload for video style selection + * Issue #35: Video Style Selection via WhatsApp Carousel + * @param {string} to - Recipient phone number + * @returns {Object} WhatsApp template message payload + */ + static buildStyleCarouselPayload(to) { + const assetsBase = process.env.ASSETS_BASE_URL || ''; + // Issue #35: Style sample images stored in template (uploaded via Meta Business Suite) + // The template uses pre-uploaded images, we just need to provide button payloads + return { + messaging_product: 'whatsapp', + to: to, + type: 'template', + template: { + name: 'video_style_selection', + language: { code: 'en' }, + components: [ + { + type: 'CAROUSEL', + cards: [ + // Card 1: Photorealistic + { + card_index: 0, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_photorealistic.png` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'style_photorealistic' }] + } + ] + }, + // Card 2: Infographic + { + card_index: 1, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_infographic.png` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'style_infographic' }] + } + ] + }, + // Card 3: Cartoon + { + card_index: 2, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_cartoon.png` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'style_cartoon' }] + } + ] + }, + // Card 4: Sketch + { + card_index: 3, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_sketch.png` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'style_sketch' }] + } + ] + } + ] + } + ] + } + }; + } + + /** + * Send style selection carousel for video generation + * Issue #35: Video Style Selection via WhatsApp Carousel + * Falls back to interactive list if carousel template fails + * @param {string} to - Recipient phone number + * @returns {Promise} + */ + static async sendStyleCarousel(to) { + try { + const payload = this.buildStyleCarouselPayload(to); + + logToFile('Attempting to send style carousel template', { + to, + templateName: 'video_style_selection' + }); + + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + const data = await response.json(); + + if (!response.ok) { + logToFile('❌ Style carousel template FAILED - using fallback list', { + to, + errorCode: data.error?.code, + errorMessage: data.error?.message, + errorDetails: data.error?.error_data?.details + }); + + // Fallback to interactive list (no images, but always works) + return await this.sendStyleListFallback(to); + } + + logToFile('✅ Style carousel sent successfully', { + to, + messageId: data.messages?.[0]?.id + }); + return true; + } catch (error) { + logToFile('❌ Style carousel exception - using fallback list', { + to, + error: error.message, + stack: error.stack + }); + + // Fallback to interactive list on any exception + return await this.sendStyleListFallback(to); + } + } + + /** + * Fallback: Send style selection as interactive list (no images) + * Used when carousel template fails (template not approved, rate limited, etc.) + * Issue #35: Fallback for carousel template failures + * @param {string} to - Recipient phone number + * @returns {Promise} + */ + static async sendStyleListFallback(to) { + try { + logToFile('Sending style selection via interactive list fallback', { to }); + + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'list', + header: { + type: 'text', + text: '🎨 Choose Video Style' + }, + body: { + text: 'Select a visual style for your educational video. Each style creates a different look and feel.' + }, + footer: { + text: 'Tap to see options' + }, + action: { + button: 'View Styles', + sections: [ + { + title: 'Video Styles', + rows: [ + { + 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' + } + ] + } + ] + } + } + }; + + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + const data = await response.json(); + + if (!response.ok) { + logToFile('❌ Style list fallback also FAILED', { + to, + errorCode: data.error?.code, + errorMessage: data.error?.message + }); + return false; + } + + logToFile('✅ Style list fallback sent successfully', { + to, + messageId: data.messages?.[0]?.id + }); + return true; + } catch (error) { + logToFile('❌ Style list fallback exception', { + to, + error: error.message + }); + return false; + } + } + + // ============================================================================ + // Feature Menu Carousel Methods + // ============================================================================ + + /** + * Feature video header handles from Resumable Upload API + * These are used when SENDING the carousel template + * Uploaded via: STAGING=true node scripts/templates/upload-menu-videos.js + */ + static FEATURE_VIDEO_HANDLES = { + lesson_plan: '4:bGVzc29uX3BsYW5fZmVhdHVyZV92Nl8yLjV4Lm1wNA==:dmlkZW8vbXA0:ARav8vOKJTl5fnsg-nyyevkOuJ6IUNuVnBK7dpP7ovG1JQLDtdoLbCUKPR19cCvnTiG_MMS32k59APiBaDeOMHtZaARSn3A1mVPS1O3vaGQRxw:e:1767013579:2002410153890842:100089382537557:ARZS6wFbgGvGa7H0wsg', + coaching: '4:Y29hY2hpbmdfZmVhdHVyZV92aWRlby5tcDQ=:dmlkZW8vbXA0:ARYspNEUJd49DiAZgZuDbHWKHzFjMpYafHMMrYoUDLTdt-xSXHo9wMZxuPZLyJW1ADiofQ-Z5mL7WC-j-unLohNTLj1X0XvO2-nVIycdtQDTjQ:e:1767013583:2002410153890842:100089382537557:ARZtLT_ZnRdQFfjPmKw', + reading: '4:cmVhZGluZ19mZWF0dXJlX3ZpZGVvXzIuNXgubXA0:dmlkZW8vbXA0:ARZ3vuitHyzNqImAOjoyY07n_JtcAmVFF0iK_q082zoFg3Z0Id9bxI40Dt0z2cUDVMqKKLkpzGonh2vkQkRBGK4fZqrrVNSn7DW4ctDuzhnUQg:e:1767013588:2002410153890842:100089382537557:ARatYbIdUUYco3hSeoU' + }; + + /** + * Build feature menu carousel payload + * Follows same pattern as buildStyleCarouselPayload - includes HEADER params + * @param {string} to - Recipient phone number + * @returns {Object} WhatsApp template message payload + */ + static buildFeatureMenuCarouselPayload(to) { + // v3: 4 cards - Lesson Plans, Video Generation, Coaching, Reading. + // Video previews require an ASSET_BASE_URL (or legacy ASSETS_BASE_URL). + // If neither is configured, the carousel still ships but with the video + // preview URLs deliberately empty — Meta rejects the send rather than + // letting a broken example-host URL go out. + if (!ASSETS_BASE_URL) { + logToFile('⚠️ ASSET_BASE_URL not configured — feature menu carousel videos will be empty', { to }); + } + return { + messaging_product: 'whatsapp', + to: to, + type: 'template', + template: { + name: 'feature_menu_carousel_v3', + language: { code: 'en' }, + components: [ + { + type: 'CAROUSEL', + cards: [ + // Card 1: Lesson Plans + { + card_index: 0, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/lesson-plans.mp4` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'menu_lesson_plan' }] + } + ] + }, + // Card 2: Video Generation + { + card_index: 1, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/video-generation-v2.mp4` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'menu_video' }] + } + ] + }, + // Card 3: Classroom Coaching + { + card_index: 2, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/classroom-coaching.mp4` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'menu_coaching' }] + } + ] + }, + // Card 4: Reading Assessment + { + card_index: 3, + components: [ + { + type: 'HEADER', + parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/reading-assessment.mp4` } }] + }, + { + type: 'BUTTON', + sub_type: 'QUICK_REPLY', + index: 0, + parameters: [{ type: 'payload', payload: 'menu_reading' }] + } + ] + } + ] + } + ] + } + }; + } + + /** + * Send feature menu carousel + * @param {string} to - Recipient phone number + * @returns {Promise} + */ + static async sendFeatureMenuCarousel(to) { + try { + logToFile('Sending feature menu carousel', { to }); + + const payload = this.buildFeatureMenuCarouselPayload(to); + + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + } + ); + + const data = await response.json(); + + if (!response.ok) { + logToFile('❌ Feature menu carousel failed, using fallback', { + to, + error: data.error?.message || 'Unknown error', + errorCode: data.error?.code, + errorDetails: JSON.stringify(data.error) + }); + return await this.sendFeatureMenuListFallback(to); + } + + logToFile('✅ Feature menu carousel sent successfully', { + to, + messageId: data.messages?.[0]?.id + }); + return true; + } catch (error) { + logToFile('❌ Feature menu carousel exception', { + to, + error: error.message + }); + return await this.sendFeatureMenuListFallback(to); + } + } + + /** + * Fallback: Send feature menu as interactive list (no videos) + * Used when carousel template is not approved or fails + * @param {string} to - Recipient phone number + * @returns {Promise} + */ + static async sendFeatureMenuListFallback(to) { + try { + logToFile('Sending feature menu list fallback', { to }); + + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to, + type: 'interactive', + interactive: { + type: 'list', + header: { + type: 'text', + text: "Here's what I can do!" + }, + body: { + text: "I'm your Rumi assistant. I can help you with lesson plans, classroom coaching, reading assessments, and more. Choose a feature to get started:" + }, + footer: { + text: 'Tap to see options' + }, + action: { + button: 'View Features', + sections: [ + { + title: 'My Features', + rows: [ + { + 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' + } + ] + } + ] + } + } + }; + + const response = await fetch( + `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + } + ); + + const data = await response.json(); + + if (!response.ok) { + logToFile('❌ Feature menu list fallback failed', { + to, + error: data.error?.message || 'Unknown error', + errorCode: data.error?.code, + errorDetails: JSON.stringify(data.error), + status: response.status + }); + return false; + } + + logToFile('✅ Feature menu list fallback sent', { + to, + messageId: data.messages?.[0]?.id + }); + return true; + } catch (error) { + logToFile('❌ Feature menu list fallback exception', { + to, + error: error.message + }); + return false; + } + } +} + +module.exports = WhatsAppService; +module.exports.buildStyleCarouselPayload = WhatsAppService.buildStyleCarouselPayload; +module.exports.sendStyleCarousel = WhatsAppService.sendStyleCarousel; +module.exports.sendStyleListFallback = WhatsAppService.sendStyleListFallback; +module.exports.sendFeatureMenuCarousel = WhatsAppService.sendFeatureMenuCarousel; +module.exports.sendFeatureMenuListFallback = WhatsAppService.sendFeatureMenuListFallback; diff --git a/bot/shared/services/messaging/pending-options.js b/bot/shared/services/messaging/pending-options.js new file mode 100644 index 0000000..db04f0b --- /dev/null +++ b/bot/shared/services/messaging/pending-options.js @@ -0,0 +1,247 @@ +/** + * Pending numbered-option menus, so a plain-text numeric reply can be turned + * back into the interactive reply the bot's router already understands. + * + * Why this exists: Meta's WhatsApp Cloud API has native buttons and list + * pickers, and bot/whatsapp-bot.js dispatches on the resulting + * `interactive.button_reply.id` / `interactive.list_reply.id` (33 distinct ID + * families — coaching_confirm_*, lang_*, style_*, quiz_*, …). Baileys has no + * reliable equivalent, so baileys-channel.service.js renders those menus as + * numbered plain text ("1. English", "2. اردو", …). + * + * That is only half a feature: without this store the user's "1" arrives as an + * ordinary text message, never matches the interactive branches, and falls + * through to general AI chat — the menu is displayed but unanswerable. So when + * the driver renders a menu it records the offered {number -> option} mapping + * here, and the inbound adapter consults it to synthesise the exact payload + * Meta would have produced. Dispatch logic stays untouched. + * + * Storage mirrors session.service.js: Redis-backed (so a menu survives the + * process restart a PaaS redeploy causes mid-conversation) with an in-memory + * fallback, and a TTL so a stale menu can't hijack a much later "1". + * + * @module pending-options + */ + +const { logToFile } = require('../../utils/logger'); + +/** + * Redis is required LAZILY, not at module load. railway-redis.service.js opens + * its connection on require, and this module is pulled in by + * baileys-channel.service.js — so a top-level require would mean merely loading + * the channel driver (as several tests and `rumi doctor` do) eagerly dials + * Redis and keeps the event loop alive. Same lazy-client convention as + * shared/storage/r2.js and baileys-connection.js's lazy `baileys` import. + */ +function redis() { + // eslint-disable-next-line global-require -- deliberate: see comment above + return require('../cache/railway-redis.service'); +} + +const KEY_PREFIX = 'baileys:pending-options:'; +/** Long enough for a user to read and answer; short enough that a stale menu expires. */ +const TTL_SECONDS = 30 * 60; + +/** In-memory fallback, used when Redis is unavailable. Map. */ +const memory = new Map(); + +function keyFor(phoneNumber) { + return `${KEY_PREFIX}${phoneNumber}`; +} + +function pruneMemory(now = Date.now()) { + for (const [phone, entry] of memory) { + if (entry.expiresAt <= now) memory.delete(phone); + } +} + +/** + * @typedef {object} PendingMenu + * @property {'button_reply'|'list_reply'} replyType which interactive shape to synthesise + * @property {Array<{id: string, title: string}>} options in the SAME order they were rendered, + * so option N corresponds to the user typing N. + */ + +/** + * Records the menu just rendered to this user, replacing any previous one. + * Best-effort: a storage failure must never break the outbound send that + * triggered it, so this only logs. + * + * @param {string} phoneNumber + * @param {PendingMenu} menu + */ +async function remember(phoneNumber, menu) { + if (!phoneNumber || !menu?.options?.length) return; + + const payload = { replyType: menu.replyType, options: menu.options }; + memory.set(phoneNumber, { expiresAt: Date.now() + TTL_SECONDS * 1000, menu: payload }); + pruneMemory(); + + try { + // NOTE: railway-redis.service.set() RETURNS FALSE rather than throwing when + // Redis isn't ready — so a try/catch alone is blind to the failure. Check + // the boolean too, or a silently-unpersisted menu looks like a success and + // only shows up as "the user's numeric reply did nothing after a restart". + const stored = await redis().set(keyFor(phoneNumber), JSON.stringify(payload), TTL_SECONDS); + if (stored === false) { + logToFile('⚠️ pending-options: Redis unavailable — menu kept in memory only (lost on restart)', { phoneNumber }); + } + } catch (error) { + logToFile('⚠️ pending-options: Redis write failed, using in-memory only', { error: error.message }); + } +} + +/** + * @param {string} phoneNumber + * @returns {Promise} + */ +async function get(phoneNumber) { + if (!phoneNumber) return null; + + try { + const raw = await redis().get(keyFor(phoneNumber)); + if (raw) return typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch (error) { + logToFile('⚠️ pending-options: Redis read failed, falling back to memory', { error: error.message }); + } + + pruneMemory(); + return memory.get(phoneNumber)?.menu || null; +} + +/** Clears the menu once answered, so the same "1" can't be replayed. */ +async function clear(phoneNumber) { + if (!phoneNumber) return; + memory.delete(phoneNumber); + try { + await redis().delete(keyFor(phoneNumber)); + } catch (error) { + logToFile('⚠️ pending-options: Redis delete failed', { error: error.message }); + } +} + +/** Lowercased, whitespace-collapsed, punctuation-trimmed — for comparing labels. */ +function normalize(s) { + return String(s || '') + .trim() + .toLowerCase() + .replace(/[.,!?)("']/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Every string that identifies this option. + * + * A label may be a composite "Group · Item" (the video picker prefixes a chapter + * onto the title), so each half counts as a label in its own right — what a + * person reads and types back is the part that identifies the item. Live testing + * caught exactly this: typing "Life Cycle of a Butterfly" for "Life Cycles of + * Living Things · Life Cycle of a Butterfly" matched nothing. + */ +function labelsFor(option) { + const labels = []; + for (const source of [option.title, option.description]) { + const whole = normalize(source); + if (!whole) continue; + labels.push(whole); + if (whole.includes('·')) { + for (const part of whole.split('·')) { + const segment = normalize(part); + if (segment) labels.push(segment); + } + } + } + return labels; +} + +/** + * Maps a raw user reply to the option it selected, or null if it isn't a + * selection at all. + * + * Accepts a NUMBER **or** the option's name, because requiring a number is + * unrealistic — people naturally type "Urdu" or "english" when shown a list of + * languages. Matching order, most explicit first: + * 1. a bare number in range → "2" + * 2. exact label match on title or description → "english", "Urdu language" + * 3. exact match on a "A · B" segment → "Life Cycle of a Frog" + * 4. exact match on the description's first word → "urdu" (from "Urdu language") + * 5. UNIQUE prefix match (≥3 chars) on either → "eng" → English + * 6. UNIQUE substring match (≥4 chars) on either → "butterfly" → the one video + * + * Still deliberately conservative, because a pending menu does NOT mean the user + * is answering it — they may just be talking. So: 1–2 char inputs never match by + * text (too collision-prone), and an ambiguous prefix matching several options + * matches nothing rather than guessing. Anything unmatched falls through to + * normal text handling. + * + * @param {PendingMenu|null} menu + * @param {string} text + * @returns {{id: string, title: string}|null} + */ +function resolveSelection(menu, text) { + if (!menu?.options?.length || typeof text !== 'string') return null; + + const raw = text.trim(); + if (!raw) return null; + + // 1. Numeric choice — the position in the rendered list. An in-range number + // always wins, because that is exactly what the "1. / 2. / 3." prompt asked + // for. Out of range, though, we do NOT give up: the digits may be the option's + // NAME. Live case — a class list ["4 - B", "5"], where the teacher replying + // "5" means the class called 5, not item five of two. Returning null there + // sent the reply to general AI chat and the quiz stalled with no explanation. + if (/^\d+$/.test(raw)) { + const index = Number.parseInt(raw, 10) - 1; + if (index >= 0 && index < menu.options.length) return menu.options[index]; + } + + const needle = normalize(raw); + + // Short replies are normally too collision-prone to match by text (a pending + // menu does not mean every message answers it). A DIGITS-ONLY reply is the + // exception: it was plainly aimed at the numbered list, so if it wasn't a + // valid position, accept it only as an EXACT name — precise enough to be safe + // at one character ("5" → the class named 5). + if (needle.length < 3) { + if (!/^\d+$/.test(needle)) return null; + const named = menu.options.filter((o) => labelsFor(o).includes(needle)); + if (named.length === 1) return named[0]; + // …or the leading token of a label, so "4" picks the class "4 - B". Still + // unique-or-nothing: with both "4 - A" and "4 - B" on offer, "4" is genuinely + // ambiguous and must not be guessed at. + const leading = menu.options.filter((o) => labelsFor(o).some((l) => l.split(' ')[0] === needle)); + return leading.length === 1 ? leading[0] : null; + } + + // 2/3. Exact match on a whole label or on one of its "·" segments. + const exact = menu.options.filter((o) => labelsFor(o).includes(needle)); + if (exact.length === 1) return exact[0]; + + // 4. First word of a label ("urdu" from "Urdu language"). + const firstWord = menu.options.filter((o) => labelsFor(o).some((l) => l.split(' ')[0] === needle)); + if (firstWord.length === 1) return firstWord[0]; + + // 5. Unique prefix match — ambiguity deliberately resolves to nothing. + const prefix = menu.options.filter((o) => labelsFor(o).some((l) => l.startsWith(needle))); + if (prefix.length === 1) return prefix[0]; + + // 6. Unique substring match. Last and least explicit, so it demands 4+ chars: + // people quote the distinctive middle of a long title ("butterfly") rather + // than retyping all of it. Ambiguity still resolves to nothing. + if (needle.length >= 4) { + const substring = menu.options.filter((o) => labelsFor(o).some((l) => l.includes(needle))); + if (substring.length === 1) return substring[0]; + } + + return null; +} + +/** Test-only: drops the in-memory fallback contents. */ +function _resetForTests() { + memory.clear(); +} + +module.exports = { + remember, get, clear, resolveSelection, TTL_SECONDS, _resetForTests, +}; diff --git a/bot/shared/services/messaging/text-flow-definitions.js b/bot/shared/services/messaging/text-flow-definitions.js new file mode 100644 index 0000000..ee6e6ad --- /dev/null +++ b/bot/shared/services/messaging/text-flow-definitions.js @@ -0,0 +1,339 @@ +/** + * The text stand-ins for this deployment's WhatsApp Flows. + * + * Registered once, at startup, by whatsapp-bot.js — but ONLY when the selected + * channel driver cannot render a real Flow (see registerTextFlowsIfNeeded). + * On Meta nothing here is ever reached: the native Flow is strictly better UX + * and is what production users get. + * + * Two shapes appear below: + * + * 1. Endpoint-backed (student-videos, settings) — declared against the very + * same bot/shared/routes/*-endpoint.js functions the Meta Flow calls, via + * endpoint-text-flow.js. No business logic is duplicated. + * + * 2. Navigate-style (reading-assessment) — a Flow with no endpoint, whose + * submission arrives as one `nfm_reply` webhook. Here the text flow + * collects the same fields and then synthesises that exact webhook shape, + * so bot/whatsapp-bot.js's existing nfm_reply dispatch and + * flow-response.handler.js run completely unchanged. The field NAMES below + * are therefore a contract with flow-response.handler.js and + * utils/flow-type-detector.js — see the tests that pin them. + * + * @module text-flow-definitions + */ + +const { logToFile } = require('../../utils/logger'); +const textFlow = require('./text-flow'); +const { buildEndpointFlow } = require('./endpoint-text-flow'); + +/** Turns collected answers into the `nfm_reply` a navigate-style Flow submits. */ +function toNfmReply(name, responseJson) { + return { + type: 'interactive', + interactive: { + type: 'nfm_reply', + nfm_reply: { + name, + body: 'Sent', + response_json: JSON.stringify(responseJson), + }, + }, + }; +} + +// ── student-videos: the pre-made curriculum video library ──────────────────── +// SELECT_GRADE -> SELECT_SUBJECT -> SELECT_TOPIC (which sends the video). +function studentVideosFlow() { + // eslint-disable-next-line global-require -- endpoint modules pull in supabase; load on use + const endpoint = require('../../routes/student-videos-endpoint'); + return buildEndpointFlow({ + kind: 'student-videos', + init: (ctx) => endpoint.handleStudentVideosInit(ctx.flowToken), + exchange: (ctx, screen, screenData) => + endpoint.handleStudentVideosDataExchange(ctx.flowToken, screen, screenData), + fallbackError: 'The video library is not available right now. Please try again later.', + stages: [ + { + screen: 'SELECT_GRADE', + fields: [{ + id: 'grade', + optionsKey: 'grades', + prompt: () => ({ header: '🎬 Student Videos', body: 'Which class are you teaching?' }), + }], + }, + { + screen: 'SELECT_SUBJECT', + fields: [{ + id: 'subject', + optionsKey: 'subjects', + prompt: (answers) => ({ body: `Which subject for ${answers.grade.title}?` }), + }], + }, + { + screen: 'SELECT_TOPIC', + fields: [{ + id: 'video', + optionsKey: 'videos', + prompt: (answers, context) => ({ + header: context.response?.data?.header_text || 'Pick a video', + body: 'Which video would you like?', + }), + }], + }, + ], + // The endpoint already sends its own "Sending your video…" ack, so adding a + // second confirmation here would double-message the teacher. + onFinish: () => null, + }); +} + +// ── settings: language + observation framework ─────────────────────────────── +// One Flow screen with two dropdowns becomes two questions. +function settingsFlow() { + // eslint-disable-next-line global-require -- see above + const endpoint = require('../../routes/settings-endpoint'); + return buildEndpointFlow({ + kind: 'settings', + init: (ctx) => endpoint.handleSettingsInit(ctx.userId), + exchange: (ctx, screen, screenData) => + endpoint.handleSettingsDataExchange(ctx.userId, screen, screenData, ctx.flowToken), + fallbackError: 'Settings could not be opened right now. Please try again later.', + stages: [ + { + screen: 'SETTINGS_MAIN', + fields: [ + { + id: 'language', + optionsKey: 'languages', + prompt: () => ({ + header: '⚙️ Rumi Settings', + body: 'Which language should I reply in?', + }), + }, + { + id: 'observation_framework', + optionsKey: 'frameworks', + prompt: (answers, context) => ({ + body: 'Which classroom-observation framework should I coach against?', + footer: context.response?.data?.info_text || undefined, + }), + }, + ], + }, + ], + onFinish: (response) => { + const data = response?.data || {}; + return [data.confirmation_message, data.details_message].filter(Boolean).join('\n') || null; + }, + }); +} + +// ── reading-assessment: navigate-style, submitted as one nfm_reply ─────────── +// Field names are the Flow v2 names flow-response.handler.js reads, and the +// "index_Label" value format it parses (it splits on "_" and matches on the +// label), so the synthesised submission is indistinguishable from a real one. +const READING_LANGUAGES = [ + { id: '0_English', title: 'English' }, + { id: '1_Urdu', title: 'Urdu' }, +]; + +const READING_MODES = [ + { id: '0_Auto', title: 'Automatic', description: 'Rumi finds the right level as you go' }, + { id: '1_Manual', title: 'Choose the level myself', description: '' }, +]; + +const READING_LEVELS = [ + { id: '0_Letters', title: 'Letters', description: 'Kindergarten' }, + { id: '1_Words', title: 'Words', description: 'Grade 1' }, + { id: '2_Sentences', title: 'Sentences', description: 'Grade 1-2' }, + { id: '3_Paragraph', title: 'Paragraph', description: 'Grade 3-5' }, +]; + +const READING_SCOPES = [ + { id: '0_Fluency_Only', title: 'Fluency only', description: 'Speed and accuracy' }, + { id: '1_Fluency_+_Comprehension', title: 'Fluency + Comprehension', description: 'Adds questions' }, +]; + +function isManualMode(answers) { + return answers.Assessment_Mode?.id === '1_Manual'; +} + +function readingAssessmentFlow() { + return { + kind: 'reading-assessment', + steps: [ + { + id: 'Student_Full_Name', + freeText: true, + prompt: () => ({ + header: '📚 Reading Assessment', + body: "What is the student's full name?", + }), + }, + { + id: 'Language', + options: () => READING_LANGUAGES, + prompt: () => ({ body: 'Which language will they read in?' }), + }, + { + id: 'Assessment_Mode', + options: () => READING_MODES, + prompt: () => ({ body: 'How should the reading level be set?' }), + }, + { + id: 'Select_the_reading_level', + // Skipped entirely in automatic mode — the handler starts at story + // level and adapts, so asking would be a question with no effect. + when: isManualMode, + options: () => READING_LEVELS, + prompt: () => ({ body: 'Which level should they start at?' }), + }, + { + id: 'Scope_of_Assessment_', + options: () => READING_SCOPES, + prompt: () => ({ body: 'What should I assess?' }), + }, + ], + async onComplete(phone, answers, context) { + const userId = context?._ctx?.userId || 'anon'; + const responseJson = { flow_token: `${userId}:reading:${phone}` }; + for (const [field, answer] of Object.entries(answers)) { + if (field.startsWith('_')) continue; + responseJson[field] = answer.id; + } + // Manual mode requires a level; automatic mode is levelled by the + // handler itself, but the field must still be present and parseable. + if (!responseJson.Select_the_reading_level) { + responseJson.Select_the_reading_level = '3_Paragraph'; + } + return { metaMessage: toNfmReply('reading_assessment', responseJson) }; + }, + }; +} + +// ── class-setup: the class + roster a teacher needs before /quiz or attendance ─ +// +// The Meta Flow for this (docs/flows/attendance-setup-flow.json) is an +// endpoint-driven LOOP — one screen per student, "Add & Continue" repeatedly — +// which is a poor fit for chat and for endpoint-text-flow.js's linear stages. +// But attendance also has a NAVIGATE format, handled by +// attendance-flow.handler.js#parseSetupFlowResponse: { class_name, section, +// attendance_frequency, student_list }, where student_list is free text parsed +// one-student-per-line by StudentListService.parseStudentText. In chat that is +// strictly nicer than the loop — the teacher pastes the roster once — so this +// synthesises that submission instead. +const ATTENDANCE_FREQUENCIES = [ + { id: 'once', title: 'Once per day' }, + { id: 'twice', title: 'Twice (morning & afternoon)' }, +]; + +function classSetupFlow() { + return { + kind: 'class-setup', + steps: [ + { + id: 'class_name', + freeText: true, + prompt: () => ({ + header: '📋 Class Setup', + body: 'Which grade is this class?\n\nFor example: 4, 5, KG-II, Nursery', + }), + }, + { + id: 'section', + freeText: true, + prompt: () => ({ + body: 'Any section? For example: A, B, Blue, Morning.\n\nReply *none* if the class has no section.', + }), + }, + { + id: 'attendance_frequency', + options: () => ATTENDANCE_FREQUENCIES, + prompt: () => ({ body: 'How often do you take attendance?' }), + }, + { + id: 'student_list', + freeText: true, + prompt: () => ({ + // The phone number is optional but prompted for, because without it + // the class cannot receive quizzes or reports — and the teacher only + // discovers that later, at the class picker. + body: 'Now send me your students — *one per line*.\n\n' + + "Add the parent's WhatsApp number if you have it, so I can send " + + 'quizzes and reports to them.\n\nFor example:\n' + + 'Ahmed Khan +923001234567\nZara s/o Abdul 03007654321\nBilal Hussain', + }), + }, + ], + async onComplete(phone, answers) { + const section = answers.section?.title?.trim() || ''; + const responseJson = { + class_name: answers.class_name.title.trim(), + // "none" is the documented escape for a class without a section; the + // handler treats an empty section as absent. + section: /^(none|no|-)$/i.test(section) ? '' : section, + attendance_frequency: answers.attendance_frequency.id, + student_list: answers.student_list.title, + }; + return { metaMessage: toNfmReply('attendance_setup', responseJson) }; + }, + }; +} + +const BUILDERS = [studentVideosFlow, settingsFlow, readingAssessmentFlow, classSetupFlow]; + +/** + * Registers every text flow. Idempotent (register() overwrites by kind), and + * lazy about requiring endpoint modules so a deployment missing one feature's + * dependencies doesn't fail startup for all of them. + */ +function registerAll() { + const registered = []; + for (const build of BUILDERS) { + try { + const definition = build(); + textFlow.register(definition); + registered.push(definition.kind); + } catch (error) { + logToFile('❌ text-flow-definitions: a flow failed to register', { error: error.message }); + } + } + return registered; +} + +let hasRegistered = false; + +/** + * Registers on first use rather than at require time — deliberately. + * + * The endpoint modules require services/whatsapp.service, which resolves to + * messaging/index.js, which requires the driver that calls this. Registering at + * module load would therefore close a require cycle and hand out a + * half-initialised driver. Doing it on the first sendFlow()/advance() call also + * means any process that sends a Flow (the bot, a worker) is covered without + * each entry point having to remember to wire it up. + */ +function ensureRegistered() { + if (hasRegistered) return; + const kinds = registerAll(); + hasRegistered = kinds.length > 0; + if (hasRegistered) logToFile('✅ Text flows registered', { kinds }); +} + +/** Test-only. */ +function _resetForTests() { + hasRegistered = false; +} + +module.exports = { + registerAll, + ensureRegistered, + _resetForTests, + toNfmReply, + ATTENDANCE_FREQUENCIES, + READING_LANGUAGES, + READING_MODES, + READING_LEVELS, + READING_SCOPES, +}; diff --git a/bot/shared/services/messaging/text-flow.js b/bot/shared/services/messaging/text-flow.js new file mode 100644 index 0000000..63c47ef --- /dev/null +++ b/bot/shared/services/messaging/text-flow.js @@ -0,0 +1,323 @@ +/** + * Multi-step TEXT flows — the sandbox stand-in for a Meta WhatsApp Flow form. + * + * Why this exists: several features are only reachable through a Meta-hosted + * Flow (a multi-screen form tied to a WABA). On the Baileys sandbox driver + * `sendFlow()` cannot work at all, and the callers do not degrade gracefully — + * e.g. text-message.handler.js's /reading test branch does + * `if (flowSent) {...} else { throw new Error('Failed to send WhatsApp Flow') }` + * and the catch replies "Sorry, something went wrong". So on sandbox the + * feature is not merely unavailable, it looks broken. Same story for /settings + * ("not available yet") and for the imported content library, whose picker is + * gated behind STUDENT_VIDEOS_FLOW_ID. + * + * This engine replaces a form with a conversation: one question per message, + * answered by number OR name (see pending-options.js#resolveSelection, which + * this reuses so "2" and "Grade 2" both work). Steps can be dynamic, so a later + * step's options can depend on earlier answers — which is what a + * grade → subject → topic picker needs. + * + * State lives in Redis (with an in-memory fallback) keyed by phone number, so a + * flow survives the process restart a PaaS redeploy causes mid-conversation. + * + * Deliberately channel-agnostic: nothing here knows about Baileys. The Baileys + * driver starts flows; Meta keeps using real Flows and never does. + * + * @module text-flow + */ + +const { logToFile } = require('../../utils/logger'); +const pendingOptions = require('./pending-options'); + +/** Lazy — railway-redis.service.js connects on require. Same reasoning as pending-options.js. */ +function redis() { + // eslint-disable-next-line global-require -- deliberate: avoid dialing Redis on module load + return require('../cache/railway-redis.service'); +} + +const KEY_PREFIX = 'text-flow:'; +/** Long enough for a multi-step picker; short enough that an abandoned flow expires. */ +const TTL_SECONDS = 30 * 60; + +/** In-memory fallback. Map */ +const memory = new Map(); + +/** + * Registered flow definitions, keyed by kind (e.g. 'student-videos'). + * + * A definition is: + * { + * kind: string, + * steps: [{ + * id: string, + * // Either a menu step… + * options?: (answers, context) => Promise + * | {options, context}>, + * prompt?: (answers, context) => Promise<{header?,body?,footer?}>, + * // …or a free-text step: + * freeText?: boolean, + * }], + * onComplete: (phone, answers, context) => Promise<{text?, metaMessage?}|void>, + * } + * + * `context` is definition-owned scratch state, persisted alongside the answers. + * A step's options() may return `{options, context}` to update it. This exists + * so a definition can carry something forward that is NOT a user answer — + * endpoint-text-flow.js uses it to remember the Flow endpoint's last response, + * so rendering step N doesn't have to replay every earlier data_exchange call + * (which would be both wasteful and unsafe if any of them had side effects). + */ +const definitions = new Map(); + +function register(definition) { + if (!definition?.kind || !Array.isArray(definition.steps)) { + throw new Error('text-flow: a definition needs { kind, steps[] }'); + } + definitions.set(definition.kind, definition); +} + +function getDefinition(kind) { + return definitions.get(kind) || null; +} + +/** + * Index of the next step that actually applies, skipping any whose `when(answers)` + * is false — the text equivalent of a Flow screen that is conditionally routed + * past (e.g. the reading assessment's level picker, which is meaningless once + * the teacher chose automatic levelling). Returns steps.length when none remain. + */ +function nextApplicableIndex(definition, answers, fromIndex) { + for (let i = fromIndex; i < definition.steps.length; i += 1) { + const step = definition.steps[i]; + if (typeof step.when !== 'function' || step.when(answers)) return i; + } + return definition.steps.length; +} + +function keyFor(phone) { + return `${KEY_PREFIX}${phone}`; +} + +function pruneMemory(now = Date.now()) { + for (const [phone, entry] of memory) { + if (entry.expiresAt <= now) memory.delete(phone); + } +} + +async function saveState(phone, state) { + memory.set(phone, { expiresAt: Date.now() + TTL_SECONDS * 1000, state }); + pruneMemory(); + try { + const stored = await redis().set(keyFor(phone), JSON.stringify(state), TTL_SECONDS); + // set() returns false (does not throw) when Redis isn't ready — surface it, + // or a flow silently becomes memory-only and dies on the next restart. + if (stored === false) { + logToFile('⚠️ text-flow: Redis unavailable — flow state is memory-only', { phone }); + } + } catch (error) { + logToFile('⚠️ text-flow: Redis write failed', { error: error.message }); + } +} + +/** @returns {Promise<{kind: string, stepIndex: number, answers: object}|null>} */ +async function getState(phone) { + if (!phone) return null; + try { + const raw = await redis().get(keyFor(phone)); + if (raw) return typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch (error) { + logToFile('⚠️ text-flow: Redis read failed, using memory', { error: error.message }); + } + pruneMemory(); + return memory.get(phone)?.state || null; +} + +async function clear(phone) { + if (!phone) return; + memory.delete(phone); + try { + await redis().delete(keyFor(phone)); + } catch (error) { + logToFile('⚠️ text-flow: Redis delete failed', { error: error.message }); + } +} + +/** + * Counts consecutive replies that did not answer the current step. + * + * Why it matters: a text flow swallows the replies it consumes, so a pending + * flow that keeps re-asking can trap the user. Not every command starts with + * "/" — "add class", "attendance" and "register" are all plain-text triggers, + * and mid-flow they look exactly like a wrong answer. Rather than teach this + * module the whole command vocabulary, the caller is given a strike count and + * can give up on the flow, letting the message be handled normally. + * + * @returns {Promise} strikes so far, including this one + */ +async function recordStrike(phone, state) { + const strikes = (state.strikes || 0) + 1; + await saveState(phone, { ...state, strikes }); + return strikes; +} + +/** True when the user is mid-flow. */ +async function isActive(phone) { + return Boolean(await getState(phone)); +} + +/** + * Renders the step at `stepIndex`, recording its menu so a numbered/named reply + * resolves. Returns what should be sent to the user, or null when the flow has + * run past its last step. + */ +async function renderStep(phone, definition, stepIndex, answers, context = {}) { + const step = definition.steps[stepIndex]; + if (!step) return null; + + if (step.freeText) { + const prompt = step.prompt ? await step.prompt(answers, context) : {}; + return { kind: 'text', prompt, options: null, context }; + } + + // options() runs BEFORE prompt() so a prompt can describe what options() + // just fetched (an endpoint-backed step learns its header from the same + // response that produced its rows). + const produced = await step.options(answers, context); + const isWrapped = produced && !Array.isArray(produced); + const options = isWrapped ? produced.options : produced; + const nextContext = isWrapped && produced.context ? produced.context : context; + + const prompt = step.prompt ? await step.prompt(answers, nextContext) : {}; + + if (!options?.length) return { kind: 'empty', prompt, options: [], context: nextContext }; + + // Reuse the menu store so resolveSelection() handles number-or-name for us. + await pendingOptions.remember(phone, { + replyType: 'list_reply', + options: options.map((o) => ({ id: o.id, title: o.title })), + }); + + return { kind: 'menu', prompt, options, context: nextContext }; +} + +/** + * Starts a flow. Returns the first step to render, or null if the kind is + * unregistered (caller should then fall back to whatever it did before). + */ +async function start(phone, kind, seedAnswers = {}, seedContext = {}) { + const definition = getDefinition(kind); + if (!definition) return null; + + const answers = { ...seedAnswers }; + const firstIndex = nextApplicableIndex(definition, answers, 0); + const state = { kind, stepIndex: firstIndex, answers, context: { ...seedContext } }; + await saveState(phone, state); + logToFile('▶️ text-flow started', { phone, kind }); + + const rendered = await renderStep(phone, definition, firstIndex, state.answers, state.context); + if (!rendered) { + await clear(phone); + return null; + } + + // Nothing to choose from (an endpoint returned no rows, or errored). Don't + // leave the user parked in a flow whose first question is unanswerable. + if (rendered.kind === 'empty') { + await clear(phone); + return rendered; + } + + await saveState(phone, { ...state, context: rendered.context || state.context }); + return rendered; +} + +/** + * Feeds the user's raw text into the active flow. + * + * @returns {Promise} + * null when no flow is active. 'unmatched' means the text was not a valid + * answer — the caller should leave it to normal message handling rather than + * swallowing it, since a pending flow does not mean every message answers it. + */ +async function advance(phone, text) { + const state = await getState(phone); + if (!state) return null; + + const definition = getDefinition(state.kind); + if (!definition) { + await clear(phone); + return null; + } + + const step = definition.steps[state.stepIndex]; + if (!step) { + await clear(phone); + return null; + } + + // Universal escape hatch — a user must always be able to get out. + if (/^(cancel|stop|exit|quit|nevermind|never mind)$/i.test(String(text || '').trim())) { + await clear(phone); + await pendingOptions.clear(phone); + logToFile('⏹️ text-flow cancelled by user', { phone, kind: state.kind }); + return { status: 'cancelled' }; + } + + let answer; + if (step.freeText) { + const trimmed = String(text || '').trim(); + if (!trimmed) return { status: 'unmatched', strikes: await recordStrike(phone, state) }; + answer = { id: trimmed, title: trimmed }; + } else { + const menu = await pendingOptions.get(phone); + const selected = pendingOptions.resolveSelection(menu, text); + if (!selected) return { status: 'unmatched', strikes: await recordStrike(phone, state) }; + await pendingOptions.clear(phone); + answer = selected; + } + + const answers = { ...state.answers, [step.id]: answer }; + const context = state.context || {}; + const nextIndex = nextApplicableIndex(definition, answers, state.stepIndex + 1); + + if (nextIndex >= definition.steps.length) { + await clear(phone); + logToFile('✅ text-flow complete', { phone, kind: state.kind }); + return { status: 'complete', kind: state.kind, answers, context, definition }; + } + + // strikes deliberately not carried over — the user just answered correctly. + await saveState(phone, { kind: state.kind, stepIndex: nextIndex, answers, context }); + const render = await renderStep(phone, definition, nextIndex, answers, context); + + // A later step with nothing to offer ends the flow rather than dead-ending + // the user on a question with no answers. + if (render?.kind === 'empty') { + await clear(phone); + return { status: 'aborted', render, answers }; + } + + await saveState(phone, { + kind: state.kind, stepIndex: nextIndex, answers, context: render?.context || context, + }); + return { status: 'step', render, answers }; +} + +/** Test-only: forget all registrations and state. */ +function _resetForTests() { + definitions.clear(); + memory.clear(); +} + +module.exports = { + register, + getDefinition, + start, + advance, + isActive, + getState, + clear, + renderStep, + TTL_SECONDS, + _resetForTests, +}; diff --git a/bot/shared/services/pic-to-lp/classifier.service.js b/bot/shared/services/pic-to-lp/classifier.service.js index 644d1f4..4e2c442 100644 --- a/bot/shared/services/pic-to-lp/classifier.service.js +++ b/bot/shared/services/pic-to-lp/classifier.service.js @@ -18,13 +18,16 @@ const OpenAI = require('openai'); const { logToFile } = require('../../utils/logger'); -const { lazyClient } = require('../../utils/lazy-client'); // Lazy-initialised so the bot can boot without OPENAI_API_KEY set; the // classifier only needs it when an image actually arrives. -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'); const VALID_TYPES = ['BOOK_PAGE', 'CLASSROOM', 'STUDENT_WORK', 'EXAM', 'OTHER']; const MODEL = process.env.PIC_LP_CLASSIFIER_MODEL || 'gpt-4o-mini'; diff --git a/bot/shared/services/pic-to-lp/metadata-extractor.service.js b/bot/shared/services/pic-to-lp/metadata-extractor.service.js index 3ca16a8..14c80f6 100644 --- a/bot/shared/services/pic-to-lp/metadata-extractor.service.js +++ b/bot/shared/services/pic-to-lp/metadata-extractor.service.js @@ -9,7 +9,6 @@ const OpenAI = require('openai'); const axios = require('axios'); const { logToFile } = require('../../utils/logger'); -const { lazyClient } = require('../../utils/lazy-client'); // Presign R2 URLs before passing to OpenAI vision. The R2 bucket is private — // raw URLs return 400 from OpenAI's image download. Without the presign the // metadata extractor silently fails (the form just opens with blank pre-fills @@ -20,9 +19,13 @@ const { extractFromCaption, mergeWithCaption } = require('./caption-prefill'); // Lazy: the bot can boot without OPENAI_API_KEY; only invoking the extractor // (i.e. a teacher sending a textbook page) needs the key. -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'); const MODEL = process.env.PIC_LP_EXTRACTOR_MODEL || 'gpt-4o-mini'; const TIMEOUT_MS = 45000; diff --git a/bot/shared/services/quiz/quiz-delivery.service.js b/bot/shared/services/quiz/quiz-delivery.service.js index 69d16b6..4bf4236 100644 --- a/bot/shared/services/quiz/quiz-delivery.service.js +++ b/bot/shared/services/quiz/quiz-delivery.service.js @@ -353,15 +353,27 @@ class QuizDeliveryService { // When all students complete, _maybeAdvanceReport enqueues a SECOND // message with delaySeconds=60. The handler's quiz_report_sent Redis // flag (24h TTL) ensures only one report ever fires per quiz. - await SQSQueueService.queueJob( - quizId, - 'quiz_report', - { teacherPhone, language }, - { - delaySeconds: 900, // 15-min cascade start; handler walks to 12h cap - deduplicationId: `${quizId}-quiz_report-initial` - } - ); + // Non-fatal, like the quiz_expire enqueue above. By this point the quiz is + // generated, marked sent, and every student has already received it — a + // failure to schedule the REPORT must not be reported to the teacher as + // "something went wrong creating the quiz", which is what happened on a + // deployment with no queue configured: students got their quiz and the + // teacher was told it had failed. + try { + await SQSQueueService.queueJob( + quizId, + 'quiz_report', + { teacherPhone, language }, + { + delaySeconds: 900, // 15-min cascade start; handler walks to 12h cap + deduplicationId: `${quizId}-quiz_report-initial` + } + ); + } catch (reportErr) { + logToFile('⚠️ Could not enqueue quiz_report (non-fatal — quiz was delivered)', { + quizId, error: reportErr.message, + }); + } // Confirm to teacher await WhatsAppService.sendMessage(teacherPhone, diff --git a/bot/shared/services/quiz/quiz-generation.service.js b/bot/shared/services/quiz/quiz-generation.service.js index bd92340..91f19bb 100644 --- a/bot/shared/services/quiz/quiz-generation.service.js +++ b/bot/shared/services/quiz/quiz-generation.service.js @@ -4,6 +4,13 @@ const { logToFile } = require('../../utils/logger'); const supabase = require('../../config/supabase'); +/** + * Output budget for one 10-question generation. See the call site for why an + * explicit bound matters rather than letting the provider reserve the model's + * full 16k output ceiling. + */ +const QUESTION_GENERATION_MAX_TOKENS = 4000; + // Question count by difficulty const QUESTION_DISTRIBUTION = [ { level: 1, count: 2 }, @@ -86,8 +93,12 @@ class QuizGenerationService { * @private */ static async _generateQuestions({ topic, grade, subject, sourceContent, quizSource }) { - const OpenAI = require('openai'); - const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + // Routed through llm-client (OpenRouter) rather than a direct OpenAI + // client: it is the single LLM entry point this codebase documents, and a + // direct client demanded OPENAI_API_KEY, which a deployment configured with + // OPENROUTER_API_KEY does not set — quiz generation died on "Missing + // credentials … set the OPENAI_API_KEY environment variable". + const openai = require('../llm-client').getClient(); const contentBlock = sourceContent ? `Based on this lesson plan content:\n${sourceContent.substring(0, 3000)}` @@ -159,7 +170,15 @@ as keys; never include the correct option as a key).`; model: 'gpt-4o', messages: [{ role: 'user', content: systemPrompt }], temperature: attempts === 0 ? 0.7 : 0.9, - response_format: { type: 'json_object' } + response_format: { type: 'json_object' }, + // Bounded deliberately. With no limit the provider reserves the + // model's entire output budget (16384 tokens for gpt-4o), which is + // ~6x what 10 questions need and is charged/quota-checked up front — + // OpenRouter rejected the request outright with "402 … You requested + // up to 16384 tokens, but can only afford 2236". 10 MCQs with + // explanations and misconception maps measure ~2.5k tokens, so this + // leaves comfortable headroom without reserving the whole ceiling. + max_tokens: QUESTION_GENERATION_MAX_TOKENS }); const raw = response.choices[0].message.content; diff --git a/bot/shared/services/quiz/quiz-intent-router.service.js b/bot/shared/services/quiz/quiz-intent-router.service.js index c53fe26..33333b4 100644 --- a/bot/shared/services/quiz/quiz-intent-router.service.js +++ b/bot/shared/services/quiz/quiz-intent-router.service.js @@ -128,18 +128,21 @@ async function _openQuizManagerFlow(user, from, topic) { async function _openAddClassFlow(user, from) { const { ATTENDANCE_SETUP_FLOW_ID } = require('../../utils/constants'); - if (!ATTENDANCE_SETUP_FLOW_ID) { - await WhatsAppService.sendMessage(from, "Sorry, class setup isn't available right now."); - return; - } - await WhatsAppService.sendFlow(from, { + // Attempted as a Flow when one is published, and as the equivalent text + // conversation otherwise (messaging/text-flow-definitions.js) — a channel + // without Flows must still be able to create the class a quiz needs. + const sent = await WhatsAppService.sendFlow(from, { flowId: ATTENDANCE_SETUP_FLOW_ID, + flowKind: 'class-setup', header: '📋 Add New Class', body: "Let's set up your class so we can send the quiz to parents.", buttonText: 'Add Class', screen: 'CLASS_INFO', flowToken: user.id }); + if (!sent) { + await WhatsAppService.sendMessage(from, "Sorry, class setup isn't available right now."); + } } async function _openEditClassFlow(user, from, cls, focus) { diff --git a/bot/shared/services/quiz/quiz-report.service.js b/bot/shared/services/quiz/quiz-report.service.js index eafe82f..1faa167 100644 --- a/bot/shared/services/quiz/quiz-report.service.js +++ b/bot/shared/services/quiz/quiz-report.service.js @@ -250,8 +250,8 @@ class QuizReportService { */ static async _generateInsightBody(quiz, stats, language) { try { - const OpenAI = require('openai'); - const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + // See quiz-generation.service.js: the single LLM entry point is llm-client. + const openai = require('../llm-client').getClient(); const prompt = `A teacher in Pakistan just received quiz results on "${quiz.topic}" (${quiz.grade || 'primary school'}). diff --git a/bot/shared/services/quiz/quiz-session.service.js b/bot/shared/services/quiz/quiz-session.service.js index 2993850..3eb3ce7 100644 --- a/bot/shared/services/quiz/quiz-session.service.js +++ b/bot/shared/services/quiz/quiz-session.service.js @@ -7,8 +7,6 @@ const WhatsAppService = require('../whatsapp.service'); const redisService = require('../cache/railway-redis.service'); const SQSQueueService = require('../queue'); // Phase 8 producer side const { computeNextDifficulty, shouldEndQuiz } = require('./quiz-adaptive'); -const OpenAI = require('openai'); -const { OPENAI_API_KEY } = require('../../utils/constants'); // Normalise phone format for Redis keys. Meta webhooks deliver // `messages[0].from` WITHOUT the leading +, but students.parent_phone is @@ -727,7 +725,8 @@ class QuizSessionService { return; // Silently ignore rapid-fire messages } - const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + // See quiz-generation.service.js: the single LLM entry point is llm-client. + const openai = require('../llm-client').getClient(); // build the system prompt with the quiz Q&A snapshot so // Rumi can answer in context. The snapshot is fetched once at diff --git a/bot/shared/services/quiz/video-quiz-report.service.js b/bot/shared/services/quiz/video-quiz-report.service.js index d669ea2..63099b2 100644 --- a/bot/shared/services/quiz/video-quiz-report.service.js +++ b/bot/shared/services/quiz/video-quiz-report.service.js @@ -320,8 +320,8 @@ async function generateGuidance(context) { const prompt = buildGuidancePrompt(context); if (!prompt) return null; try { - const OpenAI = require('openai'); - const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + // See quiz-generation.service.js: the single LLM entry point is llm-client. + const openai = require('../llm-client').getClient(); const res = await openai.chat.completions.create({ // This paragraph is the one part of the report a teacher acts on, so it // gets the better model. gpt-4o-mini produced textbook prose here — diff --git a/bot/shared/services/reading/analysis.service.js b/bot/shared/services/reading/analysis.service.js index f3eae03..cf1b551 100644 --- a/bot/shared/services/reading/analysis.service.js +++ b/bot/shared/services/reading/analysis.service.js @@ -25,6 +25,10 @@ const BenchmarkService = require('./benchmark.service'); const WhatsAppService = require('../whatsapp.service'); const FeatureLinkerService = require('../feature-linker.service'); const FeatureRegistrationService = require('../feature-registration.service'); +const fs = require('fs'); +const path = require('path'); +const { isR2Configured } = require('../../storage/r2'); +const { TEMP_DIR } = require('../../utils/constants'); const { logToFile } = require('../../utils/logger'); const { getClient } = require('../llm-client'); const { OPENAI_API_KEY } = require('../../utils/constants'); @@ -339,31 +343,77 @@ class AnalysisService { }) .eq('id', assessmentId); - // Send error message to teacher + // Tell the teacher — ONCE, and truthfully. + // + // This used to instruct the model to say "Our team has been notified", + // which is simply false: Rumi is self-hosted, there is no team watching an + // inbox, and telling a teacher help is coming when it isn't is worse than + // admitting the failure. What she can actually act on is retrying. const errorPrompt = `Generate a brief error message in language code "${userLanguage}" saying: -1. There was an error analyzing the reading assessment -2. Our team has been notified -3. They can try again or contact support -4. Apologetic tone +1. The reading assessment could not be analysed +2. The recording was not lost — she can send it again, or start over with /reading test +3. Do NOT claim that anyone has been notified, or that a team is investigating +4. Apologetic but practical tone 5. Maximum 3 sentences 6. NO markdown`; - const errorResponse = await openai.chat.completions.create({ - model: 'gpt-4o-mini', - messages: [{ role: 'user', content: errorPrompt }], - temperature: 0.3, - max_tokens: 150 - }); + let errorMessage = 'Sorry — I could not analyse that reading. Please send the recording ' + + 'again, or start over with /reading test.'; + try { + const errorResponse = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: errorPrompt }], + temperature: 0.3, + max_tokens: 150 + }); + errorMessage = errorResponse.choices[0].message.content.trim() || errorMessage; + } catch (messageError) { + // An error path must not depend on the LLM being reachable. + logToFile('⚠️ Could not generate a localised failure message, using the default', { + assessmentId, error: messageError.message, + }); + } - await WhatsAppService.sendMessage( - phoneNumber, - errorResponse.choices[0].message.content.trim() - ); + await WhatsAppService.sendMessage(phoneNumber, errorMessage); + // The caller catches this and would send its OWN error message — the + // teacher got two apologies for one failure (plus a spoken one). Flag that + // she has already been told. + error.userNotified = true; throw error; } } + /** + * Deletes local artifacts once they have served their purpose. + * + * On a deployment with no object storage, the recording and the report PDF live + * on local disk (see voice-message.handler.js and generateReport). Nothing else + * ever removes them, so every assessment would leave an .ogg and a .pdf behind + * forever — a slow disk leak, and stale copies of a child's voice sitting around + * longer than they need to. R2-hosted artifacts are left alone: they are the + * durable copy, and the report URL is stored on the assessment row. + * + * Best-effort by design: failing to tidy up must never fail an assessment that + * has already been delivered. + */ + static _cleanupLocalArtifacts({ audioUrl, reportUrl, assessmentId }) { + for (const url of [audioUrl, reportUrl]) { + if (typeof url !== 'string' || !url.startsWith('file://')) continue; + const localPath = url.slice('file://'.length); + try { + if (fs.existsSync(localPath)) { + fs.unlinkSync(localPath); + logToFile('🗑️ Removed local assessment artifact', { assessmentId, localPath }); + } + } catch (error) { + logToFile('⚠️ Could not remove local assessment artifact', { + assessmentId, localPath, error: error.message, + }); + } + } + } + /** * Generate and send fluency-only report (no comprehension) * Called when: 1) No comprehension requested, 2) Comprehension flow error fallback @@ -399,6 +449,11 @@ class AnalysisService { logToFile('Step 7/8: Sending results to teacher...'); await this.sendResults(assessment, reportUrl, phoneNumber, userLanguage); + // Delivered — local copies are no longer needed. + this._cleanupLocalArtifacts({ + audioUrl: assessment.audio_url, reportUrl, assessmentId: assessment.id, + }); + // STEP 8 (OPTIONAL): Generate voice feedback logToFile('Step 8/8: Generating voice feedback (optional)...'); try { @@ -957,6 +1012,20 @@ Output the complete enhanced summary (not just the new parts).`; const reportType = assessment.comprehension_score !== null && assessment.comprehension_score !== undefined ? 'Fluency_Comprehension' : 'Fluency_Only'; const fileName = `${reportType}_${studentName}_${dateStr}.pdf`; + // No object storage? Keep the report on local disk and hand back a file:// + // URL. sendResults() delivers it with sendDocumentFromUrl(), which resolves + // local files on the sandbox driver. Without this, a report that had already + // been RENDERED failed on upload, which marked the whole assessment 'failed' + // and sent the teacher nothing — after a student had read the passage aloud. + if (!isR2Configured()) { + const localPath = path.join(TEMP_DIR, fileName); + fs.writeFileSync(localPath, pdfBuffer); + logToFile('✅ Report PDF kept on local disk (no object storage configured)', { + path: localPath, bytes: pdfBuffer.length, + }); + return `file://${localPath}`; + } + const key = `reading_reports/${assessment.user_id}/${fileName}`; const command = new PutObjectCommand({ @@ -1515,6 +1584,11 @@ Output the complete enhanced summary (not just the new parts).`; // Send combined report to teacher await this.sendResults(assessment, publicUrl, phoneNumber, userLanguage); + // Delivered — local copies are no longer needed. + this._cleanupLocalArtifacts({ + audioUrl: assessment.audio_url, reportUrl: publicUrl, assessmentId: assessment.id, + }); + // Generate and send voice feedback (includes both fluency and comprehension) try { const voiceFeedbackUrl = await this.generateVoiceFeedback(assessment, userLanguage); diff --git a/bot/shared/services/reading/passage-generation.service.js b/bot/shared/services/reading/passage-generation.service.js index 5a9763f..1ea4219 100644 --- a/bot/shared/services/reading/passage-generation.service.js +++ b/bot/shared/services/reading/passage-generation.service.js @@ -210,6 +210,15 @@ class PassageGenerationService { const randomIndex = Math.floor(Math.random() * levelBackgrounds.length); const backgroundPath = levelBackgrounds[randomIndex]; const baseUrl = process.env.R2_PUBLIC_URL || PASSAGE_BACKGROUNDS.r2BaseUrl; + + // Presence-gated, like every other optional feature: with no base URL + // configured (R2_PUBLIC_URL unset and passage-backgrounds.json's r2BaseUrl + // empty, which is how the repo ships) there is no image to fetch, and + // interpolating an empty base produced the relative "/passage_backgrounds/…" + // that later blew up as "TypeError: Invalid URL". Backgrounds are + // decoration — no base URL just means a plain passage card. + if (!baseUrl) return null; + return `${baseUrl}/${backgroundPath}`; } @@ -223,7 +232,19 @@ class PassageGenerationService { // R2 bucket is private - need presigned URL for access const presignedUrl = await getPresignedUrl(url, 3600); // 1 hour expiry - return new Promise((resolve) => { + // https.get() throws SYNCHRONOUSLY on a non-absolute URL, and that throw + // used to escape this try/catch entirely (see the `return await` below), + // taking the whole passage down with it — a decorative background must + // never be able to do that. + if (!/^https?:\/\//i.test(String(presignedUrl || ''))) { + logToFile('⚠️ Skipping passage background — not an absolute URL', { url, presignedUrl }); + return null; + } + + // AWAITED deliberately: `return new Promise(...)` handed the promise back + // before this try/catch could see a rejection, so anything thrown inside + // the executor surfaced in the caller as an unhandled failure. + return await new Promise((resolve) => { https.get(presignedUrl, (response) => { // Handle redirects if (response.statusCode === 301 || response.statusCode === 302) { @@ -379,12 +400,23 @@ class PassageGenerationService { fs.writeFileSync(tempImagePath, imageBuffer); logToFile('📁 Passage image saved to temp file', { tempImagePath }); - // Step 4: Upload image to R2 - const imageUrl = await this.uploadPassageImage( - imageBuffer, - userId, - assessmentId - ); + // Step 4: Archive the image to R2, if R2 is configured. + // + // Non-fatal by design: this URL is only stored on the assessment row for + // later reference — Step 6 below sends the passage from the LOCAL temp + // file, never from this URL. Letting the upload throw meant a deployment + // with no object storage got "there was an error generating the passage" + // for a passage that had already been generated successfully and was + // sitting on disk ready to send ("No value provided for input HTTP label: + // Bucket", found live on the sandbox channel). + let imageUrl = null; + try { + imageUrl = await this.uploadPassageImage(imageBuffer, userId, assessmentId); + } catch (uploadError) { + logToFile('⚠️ Passage image not archived (object storage unavailable) — sending it anyway', { + assessmentId, error: uploadError.message, + }); + } // Step 5: Update assessment record (store title separately) await supabase diff --git a/bot/shared/services/reading/transcription.service.js b/bot/shared/services/reading/transcription.service.js index 3663c05..b25694e 100644 --- a/bot/shared/services/reading/transcription.service.js +++ b/bot/shared/services/reading/transcription.service.js @@ -103,6 +103,24 @@ class TranscriptionService { */ static async downloadAudio(audioUrl, assessmentId) { try { + // A file:// URL means the recording never went to object storage — see + // voice-message.handler.js, which keeps it on local disk when no bucket is + // configured. Copy it to a fresh temp path so this function's contract is + // unchanged (caller owns and deletes what it gets back) and the original + // recording isn't destroyed by that cleanup. + if (typeof audioUrl === 'string' && audioUrl.startsWith('file://')) { + const sourcePath = audioUrl.slice('file://'.length); + if (!fs.existsSync(sourcePath)) { + throw new Error(`Local audio no longer on disk: ${sourcePath}`); + } + const localTemp = path.join(TEMP_DIR, `reading_${assessmentId}_${Date.now()}.ogg`); + fs.copyFileSync(sourcePath, localTemp); + logToFile('✅ Audio read from local disk (no object storage configured)', { + sourcePath, path: localTemp, size: fs.statSync(localTemp).size, + }); + return localTemp; + } + const { downloadFromR2, extractKeyFromUrl } = require('../../storage/r2'); // Extract R2 key from URL (e.g., "audio/userId/timestamp_messageId.ogg") diff --git a/bot/shared/services/student-list.service.js b/bot/shared/services/student-list.service.js index db0dbe5..64b385e 100644 --- a/bot/shared/services/student-list.service.js +++ b/bot/shared/services/student-list.service.js @@ -18,8 +18,18 @@ class StudentListService { * - Numbered lists: "1. Name, Father" * - Bullet lists: "- Name, Father" * + * An optional parent WhatsApp number may appear anywhere in the line and is + * extracted before the name is parsed: + * - "Ahmed Khan +923001234567" + * - "Zara s/o Abdul 03001234567" + * Without a number the line parses exactly as before. This matters because + * quizzes and reports are delivered to parents: a roster with no phone numbers + * gets as far as the class picker and then stops ("your class doesn't have any + * parent phone numbers yet"), which is a dead end when the roster was entered + * as text with nowhere to put them. + * * @param {string} text - Raw student list text (one student per line) - * @returns {Array<{studentName: string, fatherName: string|null}>} + * @returns {Array<{studentName: string, fatherName: string|null, parentPhone: string|null}>} */ static parseStudentText(text) { if (!text || typeof text !== 'string') { @@ -48,6 +58,20 @@ class StudentListService { // Skip if empty after cleaning if (!cleaned) continue; + // Pull out a parent phone number, if the line carries one, BEFORE parsing + // the name — otherwise the digits end up inside the father-name field. + // Deliberately narrow: 10+ digits with optional +, spaces and dashes, so a + // roll number or a grade in the line is not mistaken for a phone. + let parentPhone = null; + const phoneMatch = cleaned.match(/\+?\d[\d\s-]{8,}\d/); + if (phoneMatch) { + parentPhone = phoneMatch[0].replace(/[\s-]/g, ''); + cleaned = cleaned.replace(phoneMatch[0], ' ').replace(/\s+/g, ' ').trim(); + // A trailing separator left behind by removing the number + cleaned = cleaned.replace(/[,;|]\s*$/, '').trim(); + } + if (!cleaned) continue; // a bare phone number identifies no student + let studentName = null; let fatherName = null; @@ -70,7 +94,7 @@ class StudentListService { } if (studentName) { - students.push({ studentName, fatherName }); + students.push({ studentName, fatherName, parentPhone }); } } @@ -180,6 +204,9 @@ class StudentListService { list_id: listId, student_name: parsedStudent.studentName, father_name: parsedStudent.fatherName || null, + // Optional — only set when the roster line carried a number. Quizzes and + // reports are delivered here. + parent_phone: parsedStudent.parentPhone || null, roll_number: parsedStudent.rollNumber, is_active: true }; diff --git a/bot/shared/services/whatsapp.service.js b/bot/shared/services/whatsapp.service.js index 5d91cd7..675481d 100644 --- a/bot/shared/services/whatsapp.service.js +++ b/bot/shared/services/whatsapp.service.js @@ -1,1918 +1,12 @@ -const axios = require('axios'); -const FormData = require('form-data'); -const fs = require('fs'); -const { WHATSAPP_TOKEN, PHONE_NUMBER_ID } = require('../utils/constants'); -const { logToFile } = require('../utils/logger'); -const { downloadFromR2, extractKeyFromUrl } = require('../storage/r2'); - -// Prefer ASSET_BASE_URL; fall back to legacy ASSETS_BASE_URL. Empty when -// neither is set — the carousel template builder below guards against that. -const ASSETS_BASE_URL = (process.env.ASSET_BASE_URL || process.env.ASSETS_BASE_URL || '').replace(/\/$/, ''); -const GRAPH_API_VERSION = process.env.GRAPH_API_VERSION || 'v21.0'; -const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`; - /** - * WhatsApp Service - * Handles all WhatsApp Cloud API interactions + * WhatsAppService — thin compatibility facade. + * + * All ~40 static methods that used to live directly in this file now live in + * bot/shared/services/messaging/ (a driver registry: meta-channel.service.js + * for the Meta Cloud API, baileys-channel.service.js for the sandbox + * driver — see docs/onboarding/sandbox-production-design.md). This file just + * re-exports whichever driver messaging/index.js resolves, so every existing + * call site across the bot (e.g. WhatsAppService.sendMessage(...)) keeps + * working unchanged, regardless of which channel is configured. */ -class WhatsAppService { - /** - * Remove emotion tags from text - * @param {string} text - Text that may contain emotion tags like [warmly], [thoughtfully], etc. - * @returns {string} Text with emotion tags removed - * @private - */ - static _removeEmotionTags(text) { - // Remove emotion tags like [warmly], [thoughtfully], [enthusiastically], etc. - // Also handles tags with spaces inside like [warm ly] - return text.replace(/\[[a-zA-Z\s]+\]\s*/g, '').trim(); - } - - /** - * Send a text message via WhatsApp - * @param {string} to - Recipient phone number - * @param {string} message - Message text - * @returns {Promise} - */ - static async sendMessage(to, message) { - try { - // Remove emotion tags from text messages (they're only for voice) - const cleanMessage = this._removeEmotionTags(message); - - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - messaging_product: 'whatsapp', - to: to, - type: 'text', - text: { body: cleanMessage }, - }), - } - ); - - const data = await response.json(); - if (!response.ok) { - logToFile('❌ Error sending WhatsApp message', { responseData: data }); - return false; - } - logToFile('✅ WhatsApp message sent', { messageId: data?.messages?.[0]?.id }); - return true; - } catch (error) { - logToFile('❌ Exception sending WhatsApp message', { error: error.message }); - return false; - } - } - - /** - * Send text and RETURN THE MESSAGE ID, optionally as a quoted reply. - * - * Every other send helper here returns a boolean, which is fine when nothing - * needs to refer back to the message. The video quiz does: an audio option's - * label is sent as a quoted reply to the clip it names (Meta: - * context.message_id), because otherwise a column of near-identical voice - * notes and a column of labels are related only by luck — a child cannot tell - * which "Sound 2" belongs to which recording. That needs the id of the - * message we just sent. - * - * Additive: no existing caller is affected. - * - * @param {string} to - * @param {string} message - * @param {Object} [opts] { contextMessageId } - * @returns {Promise} the sent message's id, or null on failure - */ - static async sendTextReturningId(to, message, opts = {}) { - try { - const cleanMessage = this._removeEmotionTags(message); - const payload = { - messaging_product: 'whatsapp', - to, - type: 'text', - text: { body: cleanMessage }, - }; - if (opts.contextMessageId) { - payload.context = { message_id: opts.contextMessageId }; - } - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - } - ); - const data = await response.json(); - if (!response.ok) { - logToFile('❌ Error sending WhatsApp message (returning id)', { responseData: data }); - return null; - } - return data?.messages?.[0]?.id || null; - } catch (error) { - logToFile('❌ Exception sending WhatsApp message (returning id)', { error: error.message }); - return null; - } - } - - /** - * Send audio from a URL and return the message id. - * Same reason as sendTextReturningId: the option label must quote this clip. - */ - static async sendAudioFromUrlReturningId(to, audioUrl) { - try { - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - messaging_product: 'whatsapp', to, type: 'audio', - audio: { link: audioUrl }, - }), - } - ); - const data = await response.json(); - if (!response.ok) { - logToFile('❌ Error sending WhatsApp audio (returning id)', { responseData: data }); - return null; - } - return data?.messages?.[0]?.id || null; - } catch (error) { - logToFile('❌ Exception sending WhatsApp audio (returning id)', { error: error.message }); - return null; - } - } - - /** - * Send a reaction to a message - * @param {string} to - Recipient phone number - * @param {string} messageId - Message ID to react to - * @param {string} emoji - Emoji to send (default: ❤️) - * @returns {Promise} - */ - static async sendReaction(to, messageId, emoji = '❤️') { - try { - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'reaction', - reaction: { - message_id: messageId, - emoji: emoji, - }, - }), - } - ); - - const data = await response.json(); - if (!response.ok) { - logToFile('Error sending reaction', data); - return false; - } - logToFile('Reaction sent successfully', { emoji, messageId }); - return true; - } catch (error) { - logToFile('Error sending reaction', { error: error.message }); - return false; - } - } - - /** - * Show typing indicator and mark message as read - * @param {string} to - Recipient phone number - * @param {string} messageId - Message ID - * @returns {Promise} - */ - static async showTypingIndicator(to, messageId) { - try { - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - messaging_product: 'whatsapp', - status: 'read', - message_id: messageId, - typing_indicator: { - type: 'text', - }, - }), - } - ); - - const data = await response.json(); - if (!response.ok) { - logToFile('Error showing typing indicator', data); - return false; - } - logToFile('Typing indicator shown'); - return true; - } catch (error) { - logToFile('Error showing typing indicator', { error: error.message }); - return false; - } - } - - /** - * Start continuous typing indicator that lasts until response is sent - * The typing indicator will be refreshed every 20 seconds to keep it active - * @param {string} to - Recipient phone number - * @param {string} messageId - Message ID - * @returns {Object} Controller object with stop() method to stop the typing indicator - */ - static startContinuousTypingIndicator(to, messageId) { - // Show typing indicator immediately - this.showTypingIndicator(to, messageId); - - // Refresh typing indicator every 20 seconds (before the 25 second timeout) - const intervalId = setInterval(() => { - this.showTypingIndicator(to, messageId); - }, 20000); // 20 seconds - - // Return a controller object to stop the typing indicator - return { - stop: () => { - clearInterval(intervalId); - logToFile('Continuous typing indicator stopped'); - } - }; - } - - /** - * Get media metadata (including duration for audio/video files) - * @param {string} mediaId - Media ID from WhatsApp - * @returns {Promise} Media metadata including url, mime_type, size, and duration (for audio/video) - */ - static async getMediaInfo(mediaId) { - try { - const mediaUrlResponse = await axios.get( - `${GRAPH_API_BASE}/${mediaId}`, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - }, - } - ); - - return mediaUrlResponse.data; - } catch (error) { - logToFile('❌ Error getting WhatsApp media info', { error: error.message }); - throw error; - } - } - - /** - * Download media from WhatsApp - * @param {string} mediaId - Media ID from WhatsApp - * @returns {Promise} - */ - static async downloadMedia(mediaId) { - try { - // Get media URL - const mediaInfo = await this.getMediaInfo(mediaId); - const mediaUrl = mediaInfo.url; - - // Download media file - const mediaResponse = await axios.get(mediaUrl, { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - }, - responseType: 'arraybuffer', - }); - - return Buffer.from(mediaResponse.data); - } catch (error) { - logToFile('❌ Error downloading WhatsApp media', { error: error.message }); - throw error; - } - } - - /** - * Send a document via WhatsApp - * @param {string} to - Recipient phone number - * @param {string} filePath - Path to the document file - * @param {string} filename - Filename to display - * @param {string} caption - Document caption - * @returns {Promise} - */ - static async sendDocument(to, filePath, filename, caption) { - try { - // Determine MIME type based on file extension - const ext = filename.toLowerCase().split('.').pop(); - const mimeTypes = { - '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' - }; - const contentType = mimeTypes[ext] || 'application/octet-stream'; - - // Upload document to WhatsApp - const formData = new FormData(); - formData.append('file', fs.createReadStream(filePath), { - contentType: contentType, - filename: filename, - }); - formData.append('messaging_product', 'whatsapp'); - - const uploadResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, - formData, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - ...formData.getHeaders(), - }, - } - ); - - const mediaId = uploadResponse.data.id; - - // Send document message - const sendResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - messaging_product: 'whatsapp', - to: to, - type: 'document', - document: { - id: mediaId, - caption: caption, - filename: filename - }, - }, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('Document sent successfully', { response: sendResponse.data }); - return true; - } catch (error) { - logToFile('Error sending document', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send an audio message via WhatsApp - * @param {string} to - Recipient phone number - * @param {Buffer} audioBuffer - Audio file buffer - * @param {string} tempDir - Temporary directory for files - * @returns {Promise} - */ - static async sendAudio(to, audioBuffer, tempDir) { - const path = require('path'); - - try { - // Save audio to temp file - const audioPath = path.join(tempDir, `audio_${Date.now()}.mp3`); - fs.writeFileSync(audioPath, audioBuffer); - - // Upload media to WhatsApp - const formData = new FormData(); - formData.append('file', fs.createReadStream(audioPath), { - contentType: 'audio/mpeg', - filename: 'audio.mp3', - }); - formData.append('messaging_product', 'whatsapp'); - - const uploadResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, - formData, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - ...formData.getHeaders(), - }, - } - ); - - const mediaId = uploadResponse.data.id; - - // Send audio message - const sendResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - messaging_product: 'whatsapp', - to: to, - type: 'audio', - audio: { - id: mediaId, - }, - }, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - // Clean up temp file - fs.unlinkSync(audioPath); - - logToFile('Audio message sent successfully', { response: sendResponse.data }); - return true; - } catch (error) { - logToFile('❌ Error sending audio message', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send a document from URL via WhatsApp - * @param {string} to - Recipient phone number - * @param {string} documentUrl - URL of the document in R2 storage - * @param {string} filename - Filename for the document - * @param {string} caption - Optional caption - * @returns {Promise} - */ - static async sendDocumentFromUrl(to, documentUrl, filename, caption) { - const path = require('path'); - const tempDir = path.join(__dirname, '../../temp'); - - try { - // Extract R2 key from URL and download using R2 client - logToFile('Downloading document from R2', { documentUrl }); - const key = extractKeyFromUrl(documentUrl); - const documentBuffer = await downloadFromR2(key); - - // Save to temp file - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - const tempFilePath = path.join(tempDir, `temp_${Date.now()}_${filename}`); - fs.writeFileSync(tempFilePath, documentBuffer); - - logToFile('Document downloaded from R2, sending to WhatsApp', { tempFilePath, size: documentBuffer.length }); - - // Use existing sendDocument method - const result = await this.sendDocument(to, tempFilePath, filename, caption); - - // Clean up temp file - if (fs.existsSync(tempFilePath)) { - fs.unlinkSync(tempFilePath); - } - - return result; - } catch (error) { - logToFile('❌ Error sending document from URL', { - error: error.message, - documentUrl, - stack: error.stack - }); - return false; - } - } - - /** - * Send audio from URL via WhatsApp - * @param {string} to - Recipient phone number - * @param {string} audioUrl - URL of the audio file in R2 storage - * @returns {Promise} - */ - static async sendAudioFromUrl(to, audioUrl) { - const path = require('path'); - const tempDir = path.join(__dirname, '../../temp'); - - try { - // Extract R2 key from URL and download using R2 client - logToFile('Downloading audio from R2', { audioUrl }); - const key = extractKeyFromUrl(audioUrl); - const audioBuffer = await downloadFromR2(key); - - logToFile('Audio downloaded from R2, sending to WhatsApp', { audioSize: audioBuffer.length }); - - // Use existing sendAudio method - return await this.sendAudio(to, audioBuffer, tempDir); - } catch (error) { - logToFile('❌ Error sending audio from URL', { - error: error.message, - audioUrl, - stack: error.stack - }); - return false; - } - } - - /** - * Send an image from a (typically R2) URL via WhatsApp. - * R2 URLs are private — WhatsApp can't fetch them directly (see the R2 note - * in sendImageWithButtons) — so we download the bytes and hand the temp file - * to sendImage, which uploads it to the Media API. Mirrors sendDocumentFromUrl - * and sendAudioFromUrl. - * @param {string} to - Recipient phone number - * @param {string} imageUrl - URL of the image in R2 storage - * @param {string} caption - Optional caption - * @returns {Promise} - */ - static async sendImageFromUrl(to, imageUrl, caption = '') { - const path = require('path'); - const tempDir = path.join(__dirname, '../../temp'); - - try { - // Extract R2 key from URL and download using R2 client - logToFile('Downloading image from R2', { imageUrl }); - const key = extractKeyFromUrl(imageUrl); - const imageBuffer = await downloadFromR2(key); - - // Save to temp file - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - const tempFilePath = path.join(tempDir, `img_${Date.now()}.png`); - fs.writeFileSync(tempFilePath, imageBuffer); - - logToFile('Image downloaded from R2, sending to WhatsApp', { tempFilePath, size: imageBuffer.length }); - - // tempFilePath contains '/' so sendImage takes the upload-file branch. - const result = await this.sendImage(to, tempFilePath, caption); - - // Clean up temp file - if (fs.existsSync(tempFilePath)) { - fs.unlinkSync(tempFilePath); - } - - return result; - } catch (error) { - logToFile('❌ Error sending image from URL', { - error: error.message, - imageUrl, - stack: error.stack - }); - return false; - } - } - - /** - * Send an approved WhatsApp template message. - * Used for paid utility/marketing sends outside the 24h customer-service - * window (e.g. the quiz invite to cold parents). The template must already be - * approved in the WABA — a clone without it registered gets a clear Meta - * "template not found" error logged here and a false return (the caller - * continues); it is a deployment-config gap, not a code bug. - * @param {string} to - Recipient phone number - * @param {string} templateName - Approved template name - * @param {string} languageCode - Template language code (e.g. 'en', 'ur') - * @param {Array} components - Template components (header/body/button params) - * @returns {Promise} - */ - static async sendTemplate(to, templateName, languageCode, components = []) { - try { - const payload = { - messaging_product: 'whatsapp', - to, - type: 'template', - template: { - name: templateName, - language: { code: languageCode }, - ...(components && components.length ? { components } : {}), - }, - }; - - const response = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - payload, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('✅ Template message sent', { - to: to.slice(-4), - templateName, - languageCode, - response: response.data, - }); - return true; - } catch (error) { - logToFile('❌ Error sending template message', { - error: error.message, - errorDetails: error.response?.data, - templateName, - languageCode, - }); - return false; - } - } - - /** - * Send a video message via WhatsApp - * @param {string} to - Recipient phone number - * @param {Buffer} videoBuffer - Video file buffer - * @param {string} tempDir - Temporary directory for files - * @param {string} caption - Optional caption for the video - * @returns {Promise} - */ - static async sendVideo(to, videoBuffer, tempDir, caption = '') { - const path = require('path'); - - try { - // Save video to temp file - const videoPath = path.join(tempDir, `video_${Date.now()}.mp4`); - fs.writeFileSync(videoPath, videoBuffer); - - logToFile('Uploading video to WhatsApp', { size: videoBuffer.length, path: videoPath }); - - // Upload media to WhatsApp - const formData = new FormData(); - formData.append('file', fs.createReadStream(videoPath), { - contentType: 'video/mp4', - filename: 'video.mp4', - }); - formData.append('messaging_product', 'whatsapp'); - - const uploadResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, - formData, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - ...formData.getHeaders(), - }, - } - ); - - const mediaId = uploadResponse.data.id; - logToFile('Video uploaded to WhatsApp', { mediaId }); - - // Send video message - const messagePayload = { - messaging_product: 'whatsapp', - to: to, - type: 'video', - video: { - id: mediaId, - }, - }; - - // Add caption if provided - if (caption) { - messagePayload.video.caption = caption; - } - - const sendResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - messagePayload, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - // Clean up temp file - fs.unlinkSync(videoPath); - - logToFile('✅ Video message sent successfully', { response: sendResponse.data }); - return true; - } catch (error) { - logToFile('❌ Error sending video message', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send video from URL via WhatsApp (downloads from R2 first) - * @param {string} to - Recipient phone number - * @param {string} videoUrl - URL of the video file in R2 storage - * @param {string} caption - Optional caption for the video - * @returns {Promise} - */ - static async sendVideoFromUrl(to, videoUrl, caption = '') { - const path = require('path'); - const tempDir = path.join(__dirname, '../../temp'); - - try { - // Extract R2 key from URL and download using R2 client - logToFile('📹 Downloading video from R2', { videoUrl }); - const key = extractKeyFromUrl(videoUrl); - const videoBuffer = await downloadFromR2(key); - - logToFile('Video downloaded from R2, sending to WhatsApp', { videoSize: videoBuffer.length }); - - // Use existing sendVideo method - return await this.sendVideo(to, videoBuffer, tempDir, caption); - } catch (error) { - logToFile('❌ Error sending video from URL', { - error: error.message, - videoUrl, - stack: error.stack - }); - return false; - } - } - - /** - * Send an image via WhatsApp - * @param {string} to - Recipient phone number - * @param {string} mediaIdOrPath - Either a WhatsApp media ID or path to image file - * @param {string} caption - Optional caption - * @returns {Promise} - */ - static async sendImage(to, mediaIdOrPath, caption = '') { - const path = require('path'); - - try { - let mediaId; - - // Check if mediaIdOrPath is a file path or media ID - // Media IDs are numeric strings, file paths contain slashes or backslashes - const isFilePath = mediaIdOrPath.includes('/') || mediaIdOrPath.includes('\\'); - - if (isFilePath) { - // Upload image to WhatsApp - logToFile('Uploading image from file', { path: mediaIdOrPath }); - const formData = new FormData(); - const ext = path.extname(mediaIdOrPath).toLowerCase(); - const contentType = ext === '.png' ? 'image/png' : 'image/jpeg'; - - formData.append('file', fs.createReadStream(mediaIdOrPath), { - contentType: contentType, - filename: path.basename(mediaIdOrPath), - }); - formData.append('messaging_product', 'whatsapp'); - - const uploadResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, - formData, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - ...formData.getHeaders(), - }, - } - ); - - mediaId = uploadResponse.data.id; - logToFile('Image uploaded to WhatsApp', { mediaId }); - } else { - // Use provided media ID - mediaId = mediaIdOrPath; - logToFile('Using cached image media ID', { mediaId }); - } - - // Send image message - const sendResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - messaging_product: 'whatsapp', - to: to, - type: 'image', - image: { - id: mediaId, - caption: caption, - }, - }, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('Image sent successfully', { response: sendResponse.data }); - return true; - } catch (error) { - logToFile('❌ Error sending image', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send an animated sticker via WhatsApp - * @param {string} to - Recipient phone number - * @param {string} mediaIdOrPath - Either a WhatsApp media ID or path to WebP sticker file - * @returns {Promise} - */ - static async sendSticker(to, mediaIdOrPath) { - const path = require('path'); - - try { - let mediaId; - - // Check if mediaIdOrPath is a file path or media ID - // Media IDs are numeric strings, file paths contain slashes or backslashes - const isFilePath = mediaIdOrPath.includes('/') || mediaIdOrPath.includes('\\'); - - if (isFilePath) { - // Stickers are optional. The repo ships `bot/marketing/` with a README - // but no binary assets — the cloner brings their own (or skips the - // feature). If the file isn't there, log once and return false so the - // caller can move on without crashing the bot. - if (!fs.existsSync(mediaIdOrPath)) { - logToFile('Sticker file not found — skipping sticker send (cosmetic)', { - path: mediaIdOrPath, - hint: 'Add a WebP sticker at this path, or set LOADING_STICKER_MEDIA_ID in .env to use a pre-uploaded Meta media ID.', - }); - return false; - } - - // Upload WebP sticker to WhatsApp - logToFile('Uploading sticker from file', { path: mediaIdOrPath }); - const formData = new FormData(); - - formData.append('file', fs.createReadStream(mediaIdOrPath), { - contentType: 'image/webp', - filename: path.basename(mediaIdOrPath), - }); - formData.append('messaging_product', 'whatsapp'); - - const uploadResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, - formData, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - ...formData.getHeaders(), - }, - } - ); - - mediaId = uploadResponse.data.id; - logToFile('Sticker uploaded to WhatsApp', { mediaId }); - } else { - // Use provided media ID - mediaId = mediaIdOrPath; - logToFile('Using cached sticker media ID', { mediaId }); - } - - // Send sticker message - const sendResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'sticker', - sticker: { - id: mediaId - } - }, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('Sticker sent successfully', { response: sendResponse.data }); - return true; - } catch (error) { - logToFile('❌ Error sending sticker', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send an interactive button message via WhatsApp - * @param {string} to - Recipient phone number - * @param {Object} options - Button message options - * @param {string} options.body - Message body text - * @param {Array<{id: string, title: string}>} options.buttons - Array of buttons (max 3) - * @returns {Promise} - */ - static async sendInteractiveButtons(to, options) { - try { - const { body, buttons } = options; - - // WhatsApp allows max 3 buttons - if (buttons.length > 3) { - logToFile('⚠️ Too many buttons, WhatsApp allows max 3', { count: buttons.length }); - return false; - } - - // Format buttons for WhatsApp API - const formattedButtons = buttons.map(btn => ({ - type: 'reply', - reply: { - id: btn.id, - title: btn.title.substring(0, 20) // WhatsApp button title max 20 chars - } - })); - - const response = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'button', - body: { - text: body - }, - action: { - buttons: formattedButtons - } - } - }, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('Interactive button message sent successfully', { response: response.data }); - return true; - } catch (error) { - logToFile('❌ Error sending interactive button message', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send image with interactive reply buttons (for vocabulary questions) - * Word-level comprehension assessment - * Fixed R2 private URL issue - now downloads from R2 first, uploads to WhatsApp - * @param {string} to - Recipient phone number - * @param {string} imageUrl - URL of image (R2 or public URL) - * @param {string} bodyText - Question text (e.g., "Which picture shows 'tree'?") - * @param {Array<{id: string, title: string}>} buttons - Array of buttons (max 3) - * @returns {Promise} - */ - static async sendImageWithButtons(to, imageUrl, bodyText, buttons) { - const path = require('path'); - const tempDir = path.join(__dirname, '../../temp'); - - try { - // WhatsApp allows max 3 buttons - if (buttons.length > 3) { - logToFile('⚠️ Too many buttons for image message, WhatsApp allows max 3', { count: buttons.length }); - return false; - } - - // Format buttons for WhatsApp API - const formattedButtons = buttons.map(btn => ({ - type: 'reply', - reply: { - id: btn.id, - title: btn.title.substring(0, 20) // WhatsApp button title max 20 chars - } - })); - - // Check if this is an R2 URL (private endpoint) - // R2 URLs contain "r2.cloudflarestorage.com" - WhatsApp can't download from these - // We need to download first, then upload to WhatsApp to get a media_id - const isR2Url = imageUrl.includes('r2.cloudflarestorage.com'); - let imageHeader; - - if (isR2Url) { - logToFile('📥 Downloading image from R2 (private URL)', { imageUrl }); - - // Extract R2 key and download using credentials - const key = extractKeyFromUrl(imageUrl); - const imageBuffer = await downloadFromR2(key); - - // Save to temp file - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - const tempFilePath = path.join(tempDir, `vocab_${Date.now()}.png`); - fs.writeFileSync(tempFilePath, imageBuffer); - - logToFile('📤 Uploading image to WhatsApp Media API', { size: imageBuffer.length }); - - // Upload to WhatsApp Media API - const formData = new FormData(); - formData.append('file', fs.createReadStream(tempFilePath), { - contentType: 'image/png', - filename: 'vocabulary.png', - }); - formData.append('messaging_product', 'whatsapp'); - - const uploadResponse = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/media`, - formData, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - ...formData.getHeaders(), - }, - } - ); - - const mediaId = uploadResponse.data.id; - logToFile('✅ Image uploaded to WhatsApp', { mediaId }); - - // Clean up temp file - if (fs.existsSync(tempFilePath)) { - fs.unlinkSync(tempFilePath); - } - - // Use media ID instead of link - imageHeader = { id: mediaId }; - } else { - // Public URL - WhatsApp can download directly - imageHeader = { link: imageUrl }; - } - - const payload = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'button', - header: { - type: 'image', - image: imageHeader - }, - body: { text: bodyText }, - action: { - buttons: formattedButtons - } - } - }; - - const response = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - payload, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('✅ Image with buttons sent successfully', { - response: response.data, - imageUrl, - usedMediaId: isR2Url, - buttonCount: buttons.length - }); - return true; - } catch (error) { - logToFile('❌ Error sending image with buttons', { - error: error.message, - errorDetails: error.response?.data, - imageUrl - }); - return false; - } - } - - /** - * Send interactive list message (used for Reading Assessment) - * Supports WhatsApp Interactive Lists with sections and rows - * @param {string} to - WhatsApp phone number (with country code) - * @param {object} listData - List configuration object - * @returns {Promise} Success status - */ - static async sendInteractiveMessage(to, listData) { - try { - // Extract from nested structure (reading-assessment.service.js passes action.sections) - const { header, body, footer, action } = listData; - const { button, sections } = action || {}; - - // Validate sections (WhatsApp allows max 10 sections, max 10 total rows) - if (!sections || sections.length === 0) { - logToFile('⚠️ No sections provided for interactive list', { listData }); - return false; - } - - if (sections.length > 10) { - logToFile('⚠️ Too many sections, WhatsApp allows max 10', { count: sections.length }); - return false; - } - - // Count total rows across all sections - const totalRows = sections.reduce((sum, section) => sum + (section.rows?.length || 0), 0); - if (totalRows > 10) { - logToFile('⚠️ Too many rows, WhatsApp allows max 10 total', { count: totalRows }); - return false; - } - - const payload = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'list', - body: { - text: body.text || body // Support both {text: '...'} and direct string - }, - action: { - button: button || 'Options', - sections: sections - } - } - }; - - // Add optional header and footer - if (header) { - payload.interactive.header = { - type: header.type || 'text', - text: header.text || header // Support both {type: 'text', text: '...'} and direct string - }; - } - - if (footer) { - payload.interactive.footer = { - text: footer.text || footer // Support both {text: '...'} and direct string - }; - } - - const response = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - payload, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('✅ Interactive list message sent successfully', { response: response.data }); - return true; - } catch (error) { - logToFile('❌ Error sending interactive list message', { - error: error.message, - errorDetails: error.response?.data - }); - return false; - } - } - - /** - * Send a WhatsApp Flow - * @param {string} to - Recipient phone number - * @param {object} flowData - Flow configuration - * @param {string} flowData.flowId - Flow ID (e.g., '819028084215847') - * @param {string} flowData.header - Header text - * @param {string} flowData.body - Body text - * @param {string} flowData.footer - Footer text (optional) - * @param {string} flowData.buttonText - CTA button text (default: 'Start') - * @param {string} flowData.screen - Initial screen to navigate to (default: 'READING_ASSESSMENT') - * @param {string} flowData.flowToken - Custom flow token for data endpoint (optional, auto-generated if not provided) - * @returns {Promise} Success status - */ - static async sendFlow(to, flowData) { - try { - const { flowId, header, body, footer, buttonText = 'Start', screen, flowToken } = flowData; - - if (!flowId) { - logToFile('❌ Flow ID is required', { flowData }); - return false; - } - - // Determine flow action mode: - // - If screen is specified: use 'navigate' with flow_action_payload.screen (static flows) - // - If no screen but flowToken exists: use 'data_exchange' (endpoint-based flows with data_api_version 3.0+) - const useDataExchange = !screen && flowToken; - const flowAction = useDataExchange ? 'data_exchange' : 'navigate'; - - const parameters = { - flow_message_version: '3', - flow_token: flowToken || `flow_${Date.now()}`, - flow_id: flowId, - flow_cta: buttonText, - flow_action: flowAction - }; - - // Only add flow_action_payload with screen for navigate mode - if (!useDataExchange) { - parameters.flow_action_payload = { - screen: screen || 'READING_ASSESSMENT' // Default for backward compatibility - }; - } - - const payload = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'flow', - header: header ? { type: 'text', text: header } : undefined, - body: { text: body }, - footer: footer ? { text: footer } : undefined, - action: { - name: 'flow', - parameters: parameters - } - } - }; - - // Remove undefined fields - if (!payload.interactive.header) delete payload.interactive.header; - if (!payload.interactive.footer) delete payload.interactive.footer; - - logToFile('📤 Sending WhatsApp Flow', { - to, - flowId, - header, - body, - hasCustomFlowToken: !!flowToken - }); - - const response = await axios.post( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - payload, - { - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - } - ); - - logToFile('✅ WhatsApp Flow sent successfully', { - response: response.data, - flowId - }); - - return true; - } catch (error) { - logToFile('❌ Error sending WhatsApp Flow', { - error: error.message, - errorDetails: error.response?.data, - flowData - }); - return false; - } - } - - /** - * Send language selection interactive list - * Allows users to choose their preferred language via /language command - * - * @param {string} to - Recipient phone number - * @param {string} currentLanguage - User's current language for bilingual header - * @returns {Promise} - */ - static async sendLanguageSelectionList(to, currentLanguage = 'en', region = null) { - try { - logToFile('Sending language selection list', { to, currentLanguage, region }); - - const { LANGUAGES, SUPPORTED_LANGUAGES } = require('../config/supported-languages'); - - // Resolve which languages to offer from the region's config (fail-open). - // Single-deployment/per-user-region: India users see Indian languages, - // everyone else sees the default set. WhatsApp interactive lists allow at - // most 10 rows total; we reserve one for Auto-detect, so cap codes at 9. - const DEFAULT_PICKER_CODES = ['en', 'ur', 'pa-PK', 'sd-PK', 'ps-PK', 'bal-PK', 'ta-LK', 'ar', 'es']; - let codes = DEFAULT_PICKER_CODES; - try { - 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)) - : []; - // Use the region's list only if it is more specific than the trivial - // ['en'] fail-open default; otherwise keep the full default picker set. - if (fromRegion.length > 1) codes = fromRegion; - } catch (e) { - logToFile('Language picker: region lookup failed, using default set', { error: e.message }); - } - - const MAX_LANG_ROWS = 9; // 10 total minus the Auto-detect row - if (codes.length > MAX_LANG_ROWS) { - logToFile('Language picker: truncating to WhatsApp 10-row limit', { region, shown: MAX_LANG_ROWS, total: codes.length }); - codes = codes.slice(0, MAX_LANG_ROWS); - } - - const languageRows = [ - { id: 'lang_auto', title: 'Auto-detect', description: 'Let me detect your language automatically' }, - ...codes.map((code) => ({ - id: `lang_${code}`, - title: (LANGUAGES[code]?.native || code).slice(0, 24), - description: `${LANGUAGES[code]?.english || code} language`.slice(0, 72), - })), - ]; - - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'list', - header: { - type: 'text', - text: 'Select Language' - }, - body: { - text: 'Choose your preferred language. I will respond in this language for all conversations.' - }, - footer: { - text: 'You can change this anytime by typing /language' - }, - action: { - button: 'Languages', - sections: [ - { - title: 'Available Languages', - rows: languageRows - } - ] - } - } - }), - } - ); - - const data = await response.json(); - if (!response.ok) { - logToFile('❌ Error sending language selection list', { error: data }); - return false; - } - - logToFile('✅ Language selection list sent successfully', { messageId: data.messages?.[0]?.id }); - return true; - } catch (error) { - logToFile('❌ Error sending language selection list', { - error: error.message - }); - return false; - } - } - - /** - * Build style carousel payload for video style selection - * Issue #35: Video Style Selection via WhatsApp Carousel - * @param {string} to - Recipient phone number - * @returns {Object} WhatsApp template message payload - */ - static buildStyleCarouselPayload(to) { - const assetsBase = process.env.ASSETS_BASE_URL || ''; - // Issue #35: Style sample images stored in template (uploaded via Meta Business Suite) - // The template uses pre-uploaded images, we just need to provide button payloads - return { - messaging_product: 'whatsapp', - to: to, - type: 'template', - template: { - name: 'video_style_selection', - language: { code: 'en' }, - components: [ - { - type: 'CAROUSEL', - cards: [ - // Card 1: Photorealistic - { - card_index: 0, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_photorealistic.png` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'style_photorealistic' }] - } - ] - }, - // Card 2: Infographic - { - card_index: 1, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_infographic.png` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'style_infographic' }] - } - ] - }, - // Card 3: Cartoon - { - card_index: 2, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_cartoon.png` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'style_cartoon' }] - } - ] - }, - // Card 4: Sketch - { - card_index: 3, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'image', image: { link: `${assetsBase}/carousel/style_sketch.png` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'style_sketch' }] - } - ] - } - ] - } - ] - } - }; - } - - /** - * Send style selection carousel for video generation - * Issue #35: Video Style Selection via WhatsApp Carousel - * Falls back to interactive list if carousel template fails - * @param {string} to - Recipient phone number - * @returns {Promise} - */ - static async sendStyleCarousel(to) { - try { - const payload = this.buildStyleCarouselPayload(to); - - logToFile('Attempting to send style carousel template', { - to, - templateName: 'video_style_selection' - }); - - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - } - ); - - const data = await response.json(); - - if (!response.ok) { - logToFile('❌ Style carousel template FAILED - using fallback list', { - to, - errorCode: data.error?.code, - errorMessage: data.error?.message, - errorDetails: data.error?.error_data?.details - }); - - // Fallback to interactive list (no images, but always works) - return await this.sendStyleListFallback(to); - } - - logToFile('✅ Style carousel sent successfully', { - to, - messageId: data.messages?.[0]?.id - }); - return true; - } catch (error) { - logToFile('❌ Style carousel exception - using fallback list', { - to, - error: error.message, - stack: error.stack - }); - - // Fallback to interactive list on any exception - return await this.sendStyleListFallback(to); - } - } - - /** - * Fallback: Send style selection as interactive list (no images) - * Used when carousel template fails (template not approved, rate limited, etc.) - * Issue #35: Fallback for carousel template failures - * @param {string} to - Recipient phone number - * @returns {Promise} - */ - static async sendStyleListFallback(to) { - try { - logToFile('Sending style selection via interactive list fallback', { to }); - - const payload = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'list', - header: { - type: 'text', - text: '🎨 Choose Video Style' - }, - body: { - text: 'Select a visual style for your educational video. Each style creates a different look and feel.' - }, - footer: { - text: 'Tap to see options' - }, - action: { - button: 'View Styles', - sections: [ - { - title: 'Video Styles', - rows: [ - { - 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' - } - ] - } - ] - } - } - }; - - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - } - ); - - const data = await response.json(); - - if (!response.ok) { - logToFile('❌ Style list fallback also FAILED', { - to, - errorCode: data.error?.code, - errorMessage: data.error?.message - }); - return false; - } - - logToFile('✅ Style list fallback sent successfully', { - to, - messageId: data.messages?.[0]?.id - }); - return true; - } catch (error) { - logToFile('❌ Style list fallback exception', { - to, - error: error.message - }); - return false; - } - } - - // ============================================================================ - // Feature Menu Carousel Methods - // ============================================================================ - - /** - * Feature video header handles from Resumable Upload API - * These are used when SENDING the carousel template - * Uploaded via: STAGING=true node scripts/templates/upload-menu-videos.js - */ - static FEATURE_VIDEO_HANDLES = { - lesson_plan: '4:bGVzc29uX3BsYW5fZmVhdHVyZV92Nl8yLjV4Lm1wNA==:dmlkZW8vbXA0:ARav8vOKJTl5fnsg-nyyevkOuJ6IUNuVnBK7dpP7ovG1JQLDtdoLbCUKPR19cCvnTiG_MMS32k59APiBaDeOMHtZaARSn3A1mVPS1O3vaGQRxw:e:1767013579:2002410153890842:100089382537557:ARZS6wFbgGvGa7H0wsg', - coaching: '4:Y29hY2hpbmdfZmVhdHVyZV92aWRlby5tcDQ=:dmlkZW8vbXA0:ARYspNEUJd49DiAZgZuDbHWKHzFjMpYafHMMrYoUDLTdt-xSXHo9wMZxuPZLyJW1ADiofQ-Z5mL7WC-j-unLohNTLj1X0XvO2-nVIycdtQDTjQ:e:1767013583:2002410153890842:100089382537557:ARZtLT_ZnRdQFfjPmKw', - reading: '4:cmVhZGluZ19mZWF0dXJlX3ZpZGVvXzIuNXgubXA0:dmlkZW8vbXA0:ARZ3vuitHyzNqImAOjoyY07n_JtcAmVFF0iK_q082zoFg3Z0Id9bxI40Dt0z2cUDVMqKKLkpzGonh2vkQkRBGK4fZqrrVNSn7DW4ctDuzhnUQg:e:1767013588:2002410153890842:100089382537557:ARatYbIdUUYco3hSeoU' - }; - - /** - * Build feature menu carousel payload - * Follows same pattern as buildStyleCarouselPayload - includes HEADER params - * @param {string} to - Recipient phone number - * @returns {Object} WhatsApp template message payload - */ - static buildFeatureMenuCarouselPayload(to) { - // v3: 4 cards - Lesson Plans, Video Generation, Coaching, Reading. - // Video previews require an ASSET_BASE_URL (or legacy ASSETS_BASE_URL). - // If neither is configured, the carousel still ships but with the video - // preview URLs deliberately empty — Meta rejects the send rather than - // letting a broken example-host URL go out. - if (!ASSETS_BASE_URL) { - logToFile('⚠️ ASSET_BASE_URL not configured — feature menu carousel videos will be empty', { to }); - } - return { - messaging_product: 'whatsapp', - to: to, - type: 'template', - template: { - name: 'feature_menu_carousel_v3', - language: { code: 'en' }, - components: [ - { - type: 'CAROUSEL', - cards: [ - // Card 1: Lesson Plans - { - card_index: 0, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/lesson-plans.mp4` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'menu_lesson_plan' }] - } - ] - }, - // Card 2: Video Generation - { - card_index: 1, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/video-generation-v2.mp4` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'menu_video' }] - } - ] - }, - // Card 3: Classroom Coaching - { - card_index: 2, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/classroom-coaching.mp4` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'menu_coaching' }] - } - ] - }, - // Card 4: Reading Assessment - { - card_index: 3, - components: [ - { - type: 'HEADER', - parameters: [{ type: 'video', video: { link: `${ASSETS_BASE_URL}/videos/reading-assessment.mp4` } }] - }, - { - type: 'BUTTON', - sub_type: 'QUICK_REPLY', - index: 0, - parameters: [{ type: 'payload', payload: 'menu_reading' }] - } - ] - } - ] - } - ] - } - }; - } - - /** - * Send feature menu carousel - * @param {string} to - Recipient phone number - * @returns {Promise} - */ - static async sendFeatureMenuCarousel(to) { - try { - logToFile('Sending feature menu carousel', { to }); - - const payload = this.buildFeatureMenuCarouselPayload(to); - - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload) - } - ); - - const data = await response.json(); - - if (!response.ok) { - logToFile('❌ Feature menu carousel failed, using fallback', { - to, - error: data.error?.message || 'Unknown error', - errorCode: data.error?.code, - errorDetails: JSON.stringify(data.error) - }); - return await this.sendFeatureMenuListFallback(to); - } - - logToFile('✅ Feature menu carousel sent successfully', { - to, - messageId: data.messages?.[0]?.id - }); - return true; - } catch (error) { - logToFile('❌ Feature menu carousel exception', { - to, - error: error.message - }); - return await this.sendFeatureMenuListFallback(to); - } - } - - /** - * Fallback: Send feature menu as interactive list (no videos) - * Used when carousel template is not approved or fails - * @param {string} to - Recipient phone number - * @returns {Promise} - */ - static async sendFeatureMenuListFallback(to) { - try { - logToFile('Sending feature menu list fallback', { to }); - - const payload = { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: to, - type: 'interactive', - interactive: { - type: 'list', - header: { - type: 'text', - text: "Here's what I can do!" - }, - body: { - text: "I'm your Rumi assistant. I can help you with lesson plans, classroom coaching, reading assessments, and more. Choose a feature to get started:" - }, - footer: { - text: 'Tap to see options' - }, - action: { - button: 'View Features', - sections: [ - { - title: 'My Features', - rows: [ - { - 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' - } - ] - } - ] - } - } - }; - - const response = await fetch( - `${GRAPH_API_BASE}/${PHONE_NUMBER_ID}/messages`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${WHATSAPP_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload) - } - ); - - const data = await response.json(); - - if (!response.ok) { - logToFile('❌ Feature menu list fallback failed', { - to, - error: data.error?.message || 'Unknown error', - errorCode: data.error?.code, - errorDetails: JSON.stringify(data.error), - status: response.status - }); - return false; - } - - logToFile('✅ Feature menu list fallback sent', { - to, - messageId: data.messages?.[0]?.id - }); - return true; - } catch (error) { - logToFile('❌ Feature menu list fallback exception', { - to, - error: error.message - }); - return false; - } - } -} - -module.exports = WhatsAppService; -module.exports.buildStyleCarouselPayload = WhatsAppService.buildStyleCarouselPayload; -module.exports.sendStyleCarousel = WhatsAppService.sendStyleCarousel; -module.exports.sendStyleListFallback = WhatsAppService.sendStyleListFallback; -module.exports.sendFeatureMenuCarousel = WhatsAppService.sendFeatureMenuCarousel; -module.exports.sendFeatureMenuListFallback = WhatsAppService.sendFeatureMenuListFallback; +module.exports = require('./messaging'); diff --git a/bot/shared/storage/r2.js b/bot/shared/storage/r2.js index 07aaceb..97dad3f 100644 --- a/bot/shared/storage/r2.js +++ b/bot/shared/storage/r2.js @@ -723,7 +723,21 @@ async function uploadBuffer(buffer, key, contentType = 'application/octet-stream } } +/** + * Whether object storage is configured at all. + * + * Callers use this to choose a local fallback INSTEAD of failing, on deployments + * that have no bucket (a sandbox, by definition). Kept here so the credential + * list can't drift from what the S3 client actually requires. + */ +function isR2Configured() { + return Boolean( + process.env.R2_ENDPOINT && process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY + ); +} + module.exports = { + isR2Configured, uploadAudio, deleteAudio, uploadClassroomAudio, diff --git a/bot/shared/utils/logger.js b/bot/shared/utils/logger.js index e6cd3c9..42deb6d 100644 --- a/bot/shared/utils/logger.js +++ b/bot/shared/utils/logger.js @@ -49,6 +49,13 @@ function logToFile(message, data = null) { // Ignore file write errors in production (Railway has no persistent storage) } + // An interactive `rumi` command keeps the file record but not the console echo. + // Its terminal is a conversation with a person — a QR code, a wizard, a + // readiness table — and internal diagnostics interleaved with that read as + // though something went wrong. The command says what happened in its own + // words; this line is still in the log file if anyone needs it. + if (process.env.RUMI_CLI === '1') return; + // For console: output structured (single-line JSON via structured-logger) // The structured-logger will intercept this and format it properly if (enrichedData) { diff --git a/bot/shared/utils/structured-logger.js b/bot/shared/utils/structured-logger.js index 1f12202..49b53bd 100644 --- a/bot/shared/utils/structured-logger.js +++ b/bot/shared/utils/structured-logger.js @@ -64,7 +64,10 @@ class AxiomBatcher { process.on('beforeExit', () => this.flush()); process.on('SIGTERM', () => this.flush()); process.on('SIGINT', () => this.flush()); - } else { + } else if (process.env.RUMI_CLI !== '1') { + // Worth saying on a server, where silent log loss is a real problem. + // Not worth saying to someone running `rumi pair`, who has not asked for + // observability and reads "⚠️ DISABLED" as something being wrong. process.stderr.write(`[Axiom] ⚠️ Logging DISABLED - dataset=${this.dataset || 'MISSING'}, token=${this.token ? 'SET' : 'MISSING'}\n`); } } @@ -261,7 +264,7 @@ let logger; if (isDev) { // Pretty print in development (single transport is fine) logger = pino({ - level: process.env.LOG_LEVEL || 'info', + level: process.env.LOG_LEVEL || (process.env.RUMI_CLI === '1' ? 'warn' : 'info'), transport: { target: 'pino-pretty', options: { @@ -282,7 +285,7 @@ if (isDev) { } else { // Production: JSON to custom stream (stdout + Axiom HTTP) logger = pino({ - level: process.env.LOG_LEVEL || 'info', + level: process.env.LOG_LEVEL || (process.env.RUMI_CLI === '1' ? 'warn' : 'info'), formatters: { level: (label) => ({ level: label }), }, @@ -441,31 +444,47 @@ function enhanceWithCorrelation(data) { return data; } -// Override console methods to produce structured output -console.log = (...args) => { - const { message, data } = parseConsoleArgs(args); - logger.info(enhanceWithCorrelation(data), message); -}; +// ============================================================ +// Console override +// ============================================================ +// +// The bot server wants every line as structured JSON with a correlation id — +// that is what these overrides are for, and they stay on by default. +// +// An interactive command wants the exact opposite. `rumi pair` prints a QR code +// and step-by-step instructions; `rumi setup` prints a whole wizard. Any of +// those that transitively loads this file (the WhatsApp connection module does) +// would have its output wrapped in JSON and become unreadable. So the `rumi` +// commands set RUMI_CLI=1 before requiring anything, and keep the real console. +// Nothing about the server's behaviour changes. +const IS_INTERACTIVE_CLI = process.env.RUMI_CLI === '1'; + +if (!IS_INTERACTIVE_CLI) { + console.log = (...args) => { + const { message, data } = parseConsoleArgs(args); + logger.info(enhanceWithCorrelation(data), message); + }; -console.error = (...args) => { - const { message, data } = parseConsoleArgs(args); - logger.error(enhanceWithCorrelation(data), message); -}; + console.error = (...args) => { + const { message, data } = parseConsoleArgs(args); + logger.error(enhanceWithCorrelation(data), message); + }; -console.warn = (...args) => { - const { message, data } = parseConsoleArgs(args); - logger.warn(enhanceWithCorrelation(data), message); -}; + console.warn = (...args) => { + const { message, data } = parseConsoleArgs(args); + logger.warn(enhanceWithCorrelation(data), message); + }; -console.info = (...args) => { - const { message, data } = parseConsoleArgs(args); - logger.info(enhanceWithCorrelation(data), message); -}; + console.info = (...args) => { + const { message, data } = parseConsoleArgs(args); + logger.info(enhanceWithCorrelation(data), message); + }; -console.debug = (...args) => { - const { message, data } = parseConsoleArgs(args); - logger.debug(enhanceWithCorrelation(data), message); -}; + console.debug = (...args) => { + const { message, data } = parseConsoleArgs(args); + logger.debug(enhanceWithCorrelation(data), message); + }; +} // ============================================================ // Semantic Event Logging diff --git a/bot/whatsapp-bot.js b/bot/whatsapp-bot.js index 0b87d77..9ade273 100644 --- a/bot/whatsapp-bot.js +++ b/bot/whatsapp-bot.js @@ -1,7 +1,10 @@ // Structured logging - must be first to capture all console.log calls const { generateCorrelationId, runWithCorrelation } = require('./shared/utils/structured-logger'); -require('dotenv').config(); +// Anchored to the repo root rather than the working directory: `cd bot && npm +// start` otherwise looked for bot/.env, found nothing, and the bot aborted with +// "Missing REQUIRED env var(s)" on a fully configured deployment. +require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); const express = require('express'); const fs = require('fs'); @@ -240,9 +243,17 @@ app.get('/webhook', (req, res) => { }); /** - * Webhook endpoint to receive messages (POST) + * Webhook endpoint to receive messages (POST). + * + * Extracted to a named function (rather than an inline arrow passed straight + * to app.post) so it can ALSO be invoked directly with a synthetic + * Express-shaped {req, res} pair — which is exactly what + * shared/services/messaging/inbound/baileys-socket.adapter.js does for + * Baileys-sourced messages, translated into this same Meta webhook shape. + * Zero behavior change from the previous inline handler; see + * wireBaileysInboundIfSelected() below for where the Baileys path plugs in. */ -app.post('/webhook', async (req, res) => { +async function handleWebhookPost(req, res) { // Generate correlation ID for tracing this request across all logs const correlationId = generateCorrelationId(); @@ -1116,6 +1127,25 @@ app.post('/webhook', async (req, res) => { if (await VideoQuizService.handleAnswer(from, listId)) return; } + // /quiz's class picker. QuizOrchestrator.initiateQuizRequest builds these + // `quiz_class_` rows and continueWithClass() is documented + // as "Called from whatsapp-bot.js list_reply handler" — but nothing ever + // called it, so picking a class did nothing on ANY channel ("⚠️ Unknown + // list item ID") and /quiz could not be completed at all. continueWithClass + // re-reads its own Redis state (topic, session, language), so the class id + // is all it needs from here. + if (listId.startsWith('quiz_class_') && user?.id) { + const classId = listId.slice('quiz_class_'.length); + try { + const QuizOrchestrator = require('./shared/services/quiz/quiz-orchestrator.service'); + await QuizOrchestrator.continueWithClass(user, from, classId, user.language || 'en'); + } catch (quizErr) { + logToFile('❌ quiz class selection failed', { classId, error: quizErr.message }); + await WhatsAppService.sendMessage(from, 'Sorry, something went wrong. Please try /quiz again.'); + } + return; + } + // CRITICAL: Get the CURRENT session first, then query conversations in THAT session const { getOrCreateSession } = require('./shared/database/bot-helpers'); const currentSessionId = await getOrCreateSession(user.id); @@ -1405,7 +1435,9 @@ app.post('/webhook', async (req, res) => { res.status(200).send('EVENT_RECEIVED'); // Still send 200 to avoid retries } }); // End of runWithCorrelation -}); +} + +app.post('/webhook', handleWebhookPost); /** * Handle document messages (classroom audio or lesson plan uploads for coaching) @@ -1717,12 +1749,101 @@ Si no solicitaste esto, ignora este mensaje.` } }); +/** + * If CHANNEL_DRIVER resolves to `baileys`, attaches the Baileys inbound + * listener (shared/services/messaging/inbound/baileys-socket.adapter.js) so + * incoming WhatsApp Web messages reach handleWebhookPost the same way a real + * Meta webhook POST does. No-op for the `meta` channel (Express's own + * /webhook route already handles that). Failures here are logged, never + * thrown — a Baileys connection problem must not crash server boot. + */ +async function wireBaileysInboundIfSelected() { + const { resolveChannelDriver } = require('./shared/config/feature-availability'); + if (resolveChannelDriver(process.env) !== 'baileys') return; + + try { + const baileysSocketAdapter = require('./shared/services/messaging/inbound/baileys-socket.adapter'); + await baileysSocketAdapter.attach(handleWebhookPost); + } catch (error) { + logToFile('❌ Failed to attach Baileys inbound listener', { error: error.message, stack: error.stack }); + } +} + +/** + * Exit code for "the WhatsApp session is gone; a human must re-pair". Chosen as + * sysexits.h's EX_CONFIG — conventionally "don't just restart me, fix the + * configuration" — so it reads as deliberate rather than a random crash. + */ +const EXIT_CODE_CHANNEL_LOGGED_OUT = 78; + +/** + * Treat a logged-out channel session as a TERMINAL failure. + * + * baileys-connection.js already refuses to auto-reconnect on + * DisconnectReason.loggedOut (401) — but that only protects the current + * process. Every real process supervisor (systemd, PM2, Docker + * `restart: always`, Railway) restarts on exit, and each restart re-attempts + * pairing against dead credentials. That is an endless loop hammering + * WhatsApp's pairing endpoint, which is exactly how live testing repeatedly + * tripped WhatsApp's "can't link new devices right now" rate limit. + * + * So: say plainly what happened and exit with a distinctive code, letting the + * supervisor's backoff/alerting surface it to a human instead of spinning + * silently. No-op for `meta`, which holds no local session. + */ +function exitOnChannelLogout() { + const { resolveChannelDriver } = require('./shared/config/feature-availability'); + if (resolveChannelDriver(process.env) !== 'baileys') return; + + const connection = require('./shared/services/messaging/baileys-connection'); + connection.events.on('close', ({ loggedOut }) => { + if (!loggedOut) return; + logToFile('🔒 WhatsApp session is logged out — re-pairing is required, exiting', { + remedy: `delete ${connection.authDir()} and run: npm run pair:baileys`, + exitCode: EXIT_CODE_CHANNEL_LOGGED_OUT, + }); + process.exit(EXIT_CODE_CHANNEL_LOGGED_OUT); + }); +} + +/** + * Close the Baileys socket cleanly on SIGTERM/SIGINT before the process dies. + * + * Without this, an abrupt exit loses Baileys' not-yet-flushed Signal session + * state (see baileys-connection.js's close()). A PaaS redeploy sends SIGTERM on + * every release, so this runs on the normal deploy path, not just on manual + * stops. No-op for the `meta` channel, which holds no local session state. + */ +function registerChannelShutdownHandlers() { + const { resolveChannelDriver } = require('./shared/config/feature-availability'); + if (resolveChannelDriver(process.env) !== 'baileys') return; + + let shuttingDown = false; + const shutdown = async (signal) => { + if (shuttingDown) return; // a second signal must not cut the flush short + shuttingDown = true; + logToFile(`Received ${signal} — closing Baileys connection before exit`, {}); + try { + await require('./shared/services/messaging/baileys-connection').close(); + } catch (error) { + logToFile('❌ Baileys shutdown failed', { error: error.message }); + } + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + /** * Start server. Gated behind `require.main === module` so requiring this * file as a library (e.g. from a test harness or a downstream that wants the * Express `app` without its listener) does NOT bind to a port. */ function startServer() { + wireBaileysInboundIfSelected(); + registerChannelShutdownHandlers(); + exitOnChannelLogout(); return app.listen(constants.PORT, () => { // Read version from VERSION file const path = require('path'); diff --git a/docs/onboarding/sandbox-production-design.md b/docs/onboarding/sandbox-production-design.md new file mode 100644 index 0000000..4c78358 --- /dev/null +++ b/docs/onboarding/sandbox-production-design.md @@ -0,0 +1,839 @@ +# Onboarding Revamp — Sandbox vs. Production Channel Design + +> **Status: implemented on `feat/channel-driver-onboarding`, not yet merged; live-verified end to end.** +> Every section (§1–§6) has real code behind its core claims — see [Implementation +> status](#implementation-status) at the bottom for the exact, itemized state of each section (not every +> sub-detail each section originally sketched was built — e.g. §6's proposed Baileys-session offline probe +> in `doctor.js` was not). +> +> **Outbound — live-verified**, against a real WhatsApp account over a real network connection (not +> mocked): `npm run pair:baileys` completing a real QR pairing, plus all 11 non-stub `WhatsAppService` +> methods (`sendMessage`, `sendReaction`, typing indicators, `sendImage`, `sendDocument`, `sendAudio`, +> `sendVideo`, `sendSticker`, and the text-rendered interactive fallbacks) delivering real content through +> the full facade chain and confirmed received on the far end. `*FromUrl` variants are untested here only +> because this environment has no R2 credentials configured — a pre-existing, unrelated gap. +> +> **Inbound — live-verified on `baileys@7.0.0-rc14`**, running the real bot (`whatsapp-bot.js`, +> `CHANNEL_DRIVER=baileys`) and messaging it from a second, real WhatsApp account. All four supported +> inbound types went through `baileys-socket.adapter.js` → the unmodified webhook dispatch logic → the real +> handler: +> - **text** → `handleTextMessage` → AI response → reply delivered and confirmed readable on the far end, +> both on a fresh pairing AND after a process restart with no re-pair (the case that failed every time +> before the fixes in item 3 below). +> - **image** → `pic_lp` handler, classified, replied. +> - **voice note** → correctly typed `voice` from the `ptt` flag → `handleVoiceMessage` → ASR routing. +> - **document** → `handleDocumentMessage` with filename and mimetype preserved. +> +> **Features — live-verified end to end** in a later pass, once the sandbox could actually reach them (see +> §8 for the mechanism and [Implementation status](#implementation-status) for the bug list). Each of these +> was driven from a real WhatsApp client and the *reply read back from the chat*, not inferred from logs: +> +> | Feature | Verified outcome on the sandbox | +> |---|---| +> | `/menu` | text menu (Meta's carousel has no Baileys equivalent) | +> | `/language`, `/settings` | preferences written to `users.preferences` — confirmed in the DB | +> | `/video` | grade → subject → topic, then a **real curriculum video delivered** from the imported library | +> | `/reading test` | name → language → mode → level → scope, then the **passage image + instructions**; a real recording of the passage scored end to end — transcript, **WCPM and accuracy**, and the **PDF report delivered** (`status='completed'`) | +> | `set up class` / `add class` | class + roster created; `"Zara s/o Abdul"` split into student/father; optional parent phone captured | +> | `/quiz ` | class picker → **10 questions generated → delivered to the parent → answered → explanation → next question**; answers persisted | +> | image (worksheet photo) | classified, then **vision feedback naming strengths and suggestions** | +> | voice note | Soniox transcription with per-token timings and auto language detection | +> | `/status`, `/portal` | accurate answers, including an honest "not configured on this deployment" | +> +> Anything still requiring a key says so plainly rather than pretending: video *generation* needs +> `KIE_API_KEY`, lesson plans need `GAMMA_API_KEY`, pronunciation scoring needs `AZURE_SPEECH_KEY`. +> +> **Real bugs this live pass found and fixed** (none of them caught by unit tests or code review — see +> each file's doc comments and `tests/messaging/` for regression coverage): +> 1. `baileys-pair.js` attached its success listener to the first socket's own `ev`, but Baileys does an +> internal reconnect right after pairing (a brand-new socket) the stale listener never saw — fixed via +> the persistent `connection.events` emitter in `baileys-connection.js`. +> 2. `getSocket()` resolved as soon as `makeWASocket()` returned a socket shell, not once the connection was +> actually open — a real send hit "Connection Closed" before the transport was ready. Fixed in +> `baileys-connection.js`. +> 3. **THE BIG ONE — replies were sent to destinations that do not exist, so the recipient's phone showed +> "Waiting for this message. This may take a while." forever.** Baileys reports such a send as +> successful, so the logs looked perfectly healthy throughout. Two stacked causes: +> - **`baileys@6.7.23` predates WhatsApp's LID (phone-number-privacy) addressing.** It has no +> `LIDMappingStore`, so an `@lid`-addressed chat could not be resolved to a real phone number. +> Fixed by upgrading to **`baileys@7.0.0-rc14`**, which owns LID↔phone mapping natively +> (`sock.signalRepository.lidMapping.getPNForLID()`, persisted as `lid-mapping-*.json` — a fresh +> pairing wrote 706 of them). The adapter now consults that store first, keeping the old +> `key.senderPn` scrape only as a fallback. **The upgrade required no other code changes** — every +> API this driver uses survived 6→7 unchanged, which the `baileys-lib.js` wrapper + lazy loading made +> easy to verify. +> - **Then our own JID parsing swallowed the fix**: 7.x's `getPNForLID()` returns a *device-scoped* +> JID (`:0@s.whatsapp.net`). `jidToPhoneNumber()` stripped `@server` but not `:device`, and +> `toJid()`'s strip-non-digits then turned `:0` into `0` — the real number with a +> trailing zero, i.e. still a nonexistent destination. Both functions now drop the device suffix +> explicitly, and both are regression-tested. +> +> Diagnostic note worth keeping: three earlier hypotheses for this symptom (lost Signal state on an +> unclean SIGTERM, an unanswerable retry receipt, and concurrent-send ratchet corruption) were all +> **wrong** — each produced a real, defensible hardening fix (see 3a/3b/3c below) but none was the +> cause. The symptom was never a crypto/ratchet problem at all; it was a bad address. The lesson: when +> a send "succeeds" but never arrives, verify the destination before theorising about encryption. +> +> 3a. **No graceful shutdown**: `whatsapp-bot.js` had no SIGTERM/SIGINT handler and `baileys-connection.js` +> had no `close()`, so an abrupt exit could lose Baileys' not-yet-flushed auth state. Kept because a PaaS +> redeploy SIGTERMs the process on *every* release; `close()` ends the socket without logging out (the +> pairing survives), suppresses the auto-reconnect `end()` would otherwise trigger, and holds a short +> flush window. +> 3b. **Retry receipts were unanswerable**: Baileys' `getMessage` config hook defaults to +> `async () => undefined`, and its own source carries a TODO that the consumer must supply the store. +> Without it `sendMessagesAgain()` can never recover the original content, so a recipient that fails to +> decrypt is never sent a resend. Now backed by a bounded (256-entry, in-memory only — it holds +> decrypted outgoing content) store of recently sent messages. +> 3c. **Concurrent sends on one socket**: this bot emits several sends per inbound message (reaction, +> typing presence, reply) with the continuous typing indicator firing on a timer *during* the reply, so +> overlap is the normal case. All `sock.sendMessage()` calls are now serialised through one queue. +> NousResearch's hermes-agent bridge independently hit and documented this: "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." +> 4. **Baileys occasionally redelivers the identical message** (same `key.id`) via `messages.upsert` within +> under a second — observed live for both an image and a document, each fully processed and replied to +> twice. The pre-existing Redis-backed dedup in `session.service.js` has an inherent network round-trip +> race window and didn't reliably catch it. Fixed with an in-memory `isDuplicateDelivery()` guard in +> `baileys-socket.adapter.js` — synchronous, zero network dependency, catches the redelivery before either +> delivery ever reaches that race. +> 5. **A logged-out session could become an infinite restart loop against WhatsApp's pairing endpoint.** +> `baileys-connection.js` correctly refuses to auto-reconnect on `DisconnectReason.loggedOut` (401) — but +> that only protects the *current* process. Every real supervisor (systemd, PM2, Docker +> `restart: always`, Railway) restarts on exit, and each restart re-attempts pairing against dead +> credentials. This is how live testing repeatedly tripped WhatsApp's "can't link new devices right now" +> limit. A logout is now TERMINAL: `whatsapp-bot.js#exitOnChannelLogout()` logs the remedy (delete the +> auth dir, run `npm run pair:baileys`) and exits **78** (sysexits' `EX_CONFIG` — "don't just restart me, +> fix the config"), so a supervisor's backoff surfaces it to a human instead of spinning silently. +> Covered by `tests/messaging/channel-lifecycle.test.js`. +> 6. **Interactive menus rendered but were unanswerable — the bot's whole interaction model was a dead end +> on this driver.** The driver renders Meta's buttons/lists as numbered plain text, which was only half a +> feature: the user's "1" arrived as an ordinary text message, never matched +> `messageType === 'interactive'`, and fell through to general AI chat. An audit found the scale — **37 +> call sites across 5 methods, feeding 33 distinct button/list ID families** (`coaching_confirm_*`, +> `lang_*`, `style_*`, `menu_*`, `quiz_*`, `pic_lp_start_*`, `reading_grade_*`, …). Compounding it, the +> Baileys option constants had been reimplemented **without the `id` fields**, so there was nothing to +> route back to even in principle. +> Fixed with one bridge rather than 37 changes: ids restored to match `meta-channel.service.js` exactly; +> `pending-options.js` records the offered `{number -> id}` map per user (Redis-backed with in-memory +> fallback + TTL, same pattern as `session.service.js`); and the inbound adapter synthesises the exact +> `interactive.button_reply` / `list_reply` payload Meta would have sent. **Dispatch logic is untouched.** +> Live-verified end to end: `/language` → numbered menu → typed `2` → `🔢 numeric reply resolved` → +> `📋 Interactive list item selected` (the original Meta branch) → `✅ User language updated` in the DB. +> Deliberately strict: only a bare in-range number counts, so ordinary prose while a menu is open still +> reaches normal text handling. +> +> **Native tappable buttons were investigated and are NOT achievable on this channel — established +> empirically, so nobody needs to retry it.** Prompted by `ourin-baileys`, a Baileys fork advertising +> native interactive support, we ran a live spike on plain upstream `baileys@7.0.0-rc14`: +> - rc14 already has every protobuf needed (`IInteractiveMessage`, `INativeFlowMessage`, +> `INativeFlowButton{name,buttonParamsJson}`, and `interactiveResponseMessage` + +> `nativeFlowResponseMessage{name,paramsJson}` for decoding a tap). It only lacks a builder helper, so +> the message was constructed by hand and sent with `relayMessage()`. +> - Attempt 1 (protobuf only): accepted by the socket, returned a message id, **never delivered**. +> - Attempt 2 added the `` stanza node — `{tag:'biz', content:[{tag:'interactive', +> attrs:{type:'native_flow', v:'1'}, content:[{tag:'native_flow', attrs:{v:'9', name:'mixed'}}]}]}` +> passed via `additionalNodes`, which rc14 supports (`Socket/messages-send.js` destructures it at +> `relayMessage` and pushes it into the stanza). This node is *precisely* `ourin-baileys`' entire +> value-add, copied from its `Modded/message_builder.js`. Also **never delivered**. +> - Control: a plain text message over the *same* connection arrived immediately — so the session was +> healthy and the failure is specific to interactive content. +> +> Conclusion: WhatsApp drops nativeFlow/interactive messages from a non-Business sender server-side. No +> client library can change that, and `ourin-baileys` would fail identically. The numbered-text menu plus +> the numeric-reply bridge above is therefore not a stopgap — it is the correct design for this channel. +> (Independent reasons to avoid that fork anyway: it ships compiled-only with no repository, its +> `Signal/libsignal.js` is an OLDER 7.x snapshot missing rc14's `hasSenderKey`/`getSessionInfo`/`close()`, +> and its protobufs contain **zero** `interactiveResponseMessage` definitions — it could send taps it +> could not decode.) +> 7. **`rumi doctor` silently loaded no environment variables at all**: `bin/rumi.js` lives at the repo root, +> but `dotenv` is only a dependency of `bot/package.json` — a bare `require('dotenv')` there throws +> `MODULE_NOT_FOUND`, silently swallowed by a `try/catch`, so `.env` never loaded and every required var +> reported "missing" even on a fully-configured deployment. Fixed by resolving `dotenv` via +> `bot/node_modules` explicitly, the same place every `bot/scripts/setup/*.js` file already loads it from. +> +> **CLI — live-verified end to end**: `install.sh` (dependency check, root + `bot/` `npm install`, idempotent +> `.env` creation — confirmed it leaves an existing `.env` untouched, declined the `npm link` prompt and got +> the correct fallback instructions), `rumi doctor` (after the dotenv fix above, correctly reports live +> Supabase/OpenRouter/Redis checks), `rumi setup` (the full interactive wizard — Supabase → Redis → AI keys → +> channel choice — end to end; confirmed it patches `.env` surgically, appending only the one new value +> `CHANNEL_DRIVER=baileys` and touching nothing else), and `rumi graduate` (confirmed the safety path: given +> placeholder Meta credentials, it live-validates, fails loud, and leaves both `.env` and the active +> `.channel-state/baileys` session completely untouched). Not verified: `rumi graduate`'s success path +> (flipping to a real, working Meta target) — this environment has no real Meta Business App/WABA to test +> against. +> +> **Known, out-of-scope limitations surfaced by live testing** (environment/infra, not this branch): the +> `@lid` cache is session-scoped and empty on every restart, so the very first message from a contact after +> a restart can still briefly mis-resolve if it arrives via the offline-catchup path; a public/proxied Redis +> connection (needed only because this sandbox can't reach Railway's private `redis.railway.internal`) was +> visibly flaky; repeated forced restarts of the bot process (an artifact of this specific sandbox, not +> normal usage) caused visible WhatsApp session desync ("waiting for this message" on the recipient's end), +> resolved by a fresh re-pair — a real WhatsApp/Baileys characteristic under rapid reconnects, unlikely to +> affect a normally-run, continuously-up deployment. + +## Context + +Rumi's onboarding today ([`SETUP.md`](../../SETUP.md), [`.claude/skills/setup/SKILL.md`](../../.claude/skills/setup/SKILL.md)) +is an 11-step, mostly-manual flow that assumes two things that no longer hold for every user: (1) that +everyone can and will set up a real Meta WhatsApp Business App before they can try the bot at all, and (2) +that a native coding agent (Claude Code) is present to drive the process conversationally. Neither is true +for a developer who just wants to try Rumi in five minutes, or for an organization evaluating it before +committing to Meta's app-review process. + +Inspired by NousResearch's `hermes-agent` — a **two-layer CLI** (`install.sh` for mechanical bootstrap, then +the `hermes` command itself for `setup`/`doctor`/`status`/gateway config) plus a graduated "start +lightweight, scale to real infra" backend switch — this design splits onboarding into two umbrellas: + +- **Sandbox** (default): the general case — zero formal business registration needed. Baileys (WhatsApp Web + protocol) is the only sandbox driver in v1, but sandbox is not "Baileys specifically" — it's *every* + driver that isn't Meta. Slack, Telegram, Signal, etc. are all sandbox-tier the moment they're added, with + no re-classification needed. +- **Production**: **Meta only**, for v1 and structurally going forward — Meta's WhatsApp Cloud API is the + one channel that requires a formal Business App + app-review process, which is what actually makes + something "production-grade" here. Nothing else currently in scope has that requirement. +- **Graduation**: an explicit, scripted path from sandbox to production once an org is ready — command + name: `rumi graduate`. + +And it replaces the "you need Claude Code to onboard well" gap with a two-layer CLI (`install.sh` + +`rumi `) that any user can run standalone, no native coding agent required. + +**Design principles this revision locks in:** +1. The driver abstraction is a **registry with an explicit production allowlist** + (`PRODUCTION_TIER_DRIVERS = ['meta']`), not a per-driver tag that has to be set correctly by hand. + Default assumption for any driver, present or future, is sandbox — meta is the one opt-out. +2. Local per-driver session/auth state lives under **one generic root env var** + (`CHANNEL_STATE_DIR`, namespaced by driver subfolder), not a new one-off env var + folder per driver. +3. Commands are named and shaped like Hermes's: `./install.sh` (one-time mechanical bootstrap) → `rumi + setup` (interactive wizard), `rumi graduate`, `rumi doctor`. +4. The wizard's channel question uses plain language, not jargon: **"Just testing things out"** (default) + vs. **"Real deployment — I have everything I need"** — the technical `CHANNEL_DRIVER` value is set behind + that choice, never shown as the question itself. + +v1 scope: only **Baileys ships as a driver so far** (sandbox itself is open-ended by design — the registry +is N-channel-shaped from day one, Slack/Telegram are future registry entries, not a future rearchitecture); +the setup wizard is **fully interactive** for every required var, not just channel choice; graduation is a +**real `rumi graduate` command**, not docs-only. + +## Flow at a glance + +``` + ┌─────────────────────┐ + │ ./install.sh │ one-time mechanical bootstrap: + │ (mechanical layer) │ deps, npm install, .env copy, + └──────────┬───────────┘ wires up the `rumi` command + │ + ▼ + ┌─────────────────────┐ + │ rumi setup │ + │ (fully interactive) │ + └──────────┬───────────┘ + ┌──────────────────────────────────┐ + │ Supabase → Redis → AI keys │ (channel-independent, + │ (bootstrap:db, validate:env) │ same for every driver) + └──────────────────┬─────────────────┘ + │ + "How are you using Rumi right now?" (plain-language, not jargon) + [Just testing things out] (default) [Real deployment — I have everything I need] + → any sandbox-tier driver → meta (the sole production-tier driver) + (baileys today; slack/telegram later, + picked from the registry, same prompt) + │ │ + ┌───────────────┘ └───────────────┐ + ▼ ▼ + Baileys QR pairing Meta 4-value credential + CHANNEL_DRIVER=baileys collection (existing docs) + (no Meta account needed) CHANNEL_DRIVER=meta + │ │ + ▼ ▼ + doctor (baileys-scoped) doctor (meta-scoped) + + → next-steps banner run-full-setup.js (Flows) + → "message your own number" → next-steps banner + │ + │ (org decides to go live) + ▼ + rumi graduate --to=meta + → validates target creds live + → flips CHANNEL_DRIVER=meta + → re-registers Flows + → retires CHANNEL_STATE_DIR/baileys + → prints Meta-console manual steps + │ + ▼ + Production flow +``` + +## 1. Channel driver abstraction — an N-channel registry, not a meta/sandbox binary + +Meta and Baileys are just the first two of many drivers to come — Slack, Telegram, Signal, and others, the +same way Hermes bridges 20+ platforms behind one gateway. So this is a **registry of named drivers**. Tier +("sandbox" vs "production") is not a per-driver tag someone has to remember to set correctly — it's a +**default-sandbox rule with an explicit production allowlist**: every driver is sandbox-tier unless it's on +that allowlist, and for v1 the allowlist has exactly one entry (`meta`), because Meta's WhatsApp Cloud API +is the one channel that requires a formal Business App + app-review process — the actual thing that makes a +channel "production-grade" here. Adding Slack/Telegram later needs zero tier bookkeeping; they're sandbox by +just not being on the list. + +Modeled on the existing, proven pattern in `bot/shared/services/queue/index.js` (`QUEUE_DRIVER` selecting +`sqs` vs `bullmq`, identical method surface, unknown-value warn-and-fallback) — that file's *shape* (one +selector, identical surface per implementation) is exactly right; its *content* (a hardcoded two-way +if/else) is what needs generalizing into a registry so a third, fourth, fifth driver is a pure addition, +never a rewrite. + +- **New module `bot/shared/services/messaging/`**: + - `channel-registry.js` — a plain map, `DRIVERS = { meta: './meta-channel.service', baileys: + './baileys-channel.service' }` for v1, with future entries (`slack`, `telegram`, ...) added as new keys + only; plus the one-line allowlist, `PRODUCTION_TIER_DRIVERS = ['meta']`, that the setup wizard/docs read + to decide what counts as "real deployment" — never consulted by runtime message-handling code, which + only ever cares about the driver name, not its tier. + - `index.js` — reads `CHANNEL_DRIVER` (default `baileys`), looks it up in `channel-registry.js`, requires + that module; unknown value warns and falls back to `baileys` (same warn-and-fallback UX as + `QUEUE_DRIVER`, just registry-driven instead of if/else-driven). + - `meta-channel.service.js` — the current Graph-API code, lifted mechanically out of + `bot/shared/services/whatsapp.service.js` (same ~40 static methods, same `GRAPH_API_BASE`/ + `WHATSAPP_TOKEN`/`PHONE_NUMBER_ID` env reads). + - `baileys-channel.service.js` — new Baileys-backed implementation of the same method names. Named for + the actual driver, not "sandbox" — a future Telegram driver is just as much a sandbox-tier option and + shouldn't have to share a name that implies it's the only one. + - `channel-capabilities.js` — per-driver flags (`supportsFlows`, `supportsTemplates`, + `supportsInteractiveButtons`, `supportsInteractiveList`, `supportsCarousel`, `templateRenderMode` — see + Templates below) — the seam every future driver plugs into without touching a single call site or the + registry's dispatch logic. +- `bot/shared/services/whatsapp.service.js` becomes a **thin compatibility facade** + (`module.exports = require('./messaging')`), so the existing ~40+ call sites across handlers/routes/ + workers need **zero changes**. Blast radius is contained entirely to the new `messaging/` directory — + run the `cross-agent-safety` checklist against it regardless, since it's still a shared-service change. + +**Local per-driver state — one root env var, not one-off vars per driver.** Baileys needs to persist auth +state locally (its QR-pairing session), and any future driver may need its own local state too (a Telegram +bot-token file, a Slack app-token cache, etc.). Rather than a differently-named env var + top-level folder +per driver — that scales badly, cluttering `.env` and the repo root with one folder per driver added — v1 +introduces a **single generic root**: +``` +CHANNEL_STATE_DIR=.channel-state # local session/auth state root — one var, ever +``` +Each driver keeps its state in its own subfolder, namespaced automatically by driver name: +`.channel-state/baileys/`, and later `.channel-state/telegram/`, etc. — the driver never needs its own env +var, it just reads `path.join(CHANNEL_STATE_DIR, )`. One `.gitignore` entry +(`.channel-state/`) covers every driver forever, current and future. + +### Templates: a channel-agnostic content registry, not a Meta-only feature + +Porting `sendTemplate` to Baileys isn't really porting Meta's template *system*, because that system's +approval workflow and 24-hour-window rule are artifacts of Meta's Business Platform policy layer, which +Baileys is never subject to at all (Baileys automates an ordinary chat session with no such restriction). +What's genuinely portable is the *content* — header/body/footer/buttons and their variables — which today +only exists as Meta template registrations (`bot/scripts/templates/create-menu-carousel.js`, +`upload-menu-videos-v3.js`, etc.), with no channel-independent representation. + +v1 design: extract template content into a new `bot/shared/services/messaging/templates/` registry (one +definition per template — id, header/body/footer text with `{{variables}}`, optional buttons/list items), +fully channel-independent. Each driver renders it its own way, selected by `channel-capabilities.js`'s new +`templateRenderMode` flag: +- **`meta`** → `templateRenderMode: 'native-template'`: maps the registry id to the Meta-approved template + name and sends via the existing Graph template payload (approval/registration stays an external, + operational step — unchanged from today). +- **`baileys`** → `templateRenderMode: 'native-template'` too, since Baileys is still the WhatsApp wire + protocol: interpolate the variables directly into a normal formatted WhatsApp message (text + native + buttons/list where supported) and send immediately — no approval step, no window restriction, because + neither applies outside Meta's Business Platform. +- **Future non-WhatsApp drivers** (Slack, Telegram, ...) → `templateRenderMode: 'slash-command'`: the same + registry entry is exposed as that platform's native command primitive (a Slack `/command`, a Telegram bot + command) instead of being forced into a "template message" shape that doesn't fit those platforms. + WhatsApp-family channels keep the template format; other platforms project the same content through their + own native command UX. + +Net effect: `sendTemplate(templateId, params, to)` is one call in `messaging/index.js` regardless of driver, +and adding a new channel later means writing one renderer function against the same registry — no changes +to the registry itself or to any call site. + +**Method-by-method fit for the rest (confirmed against the actual file, not assumed):** +- Trivially portable 1:1 across every channel: `sendMessage`, `sendReaction`, presence/typing, media + send/download, document/audio/image/video/sticker sends. +- Flow-dependent (`sendFlow`): **the codebase already has the exact fallback convention needed** — verified + live: `text-message.handler.js:1212` (`if (!SETTINGS_FLOW_ID) { ...text fallback... }`), + `homework-trigger.js:14` ("the flow is offered iff HOMEWORK_FLOW_ID..."), and similar guards for + `STATUS_FLOW_ID`, `STUDENT_VIDEOS_FLOW_ID`. **v1 strategy: never set any `*_FLOW_ID` when + `CHANNEL_DRIVER=baileys`, and the existing unset-ID fallback paths take over automatically** — no new + fallback logic needs to be built; `baileys-channel.service.js`'s `sendFlow` only needs a defensive + log-and-no-op for anything that slips through. A future non-WhatsApp driver would instead route Flow + content through the same `slash-command`/native-form mechanism as templates. (Note: the + `sendStyleListFallback`/`sendFeatureMenuListFallback` methods found in `whatsapp.service.js:1419/1681` are + a *different*, adjacent mechanism — runtime failure fallback for the carousel template, not a + config-presence fallback — good to know but not the mechanism `baileys` relies on.) + +**Inbound side is the hidden half of this abstraction** — `WhatsAppService` only covers *outbound* sends. +Inbound parsing (`bot/whatsapp-bot.js`'s ~1700-line `app.post('/webhook')`) is baked around Meta's wire +format (`entry[0].changes[0].value.messages`, `hub.mode` verification, `nfm_reply`). Baileys has no HTTP +webhook — it's a persistent socket (`makeWASocket`) emitting `messages.upsert`; a future Slack/Telegram +driver would have yet another shape (Events API, long-polling). v1 needs: +- `messaging/inbound/meta-webhook.adapter.js` — the extracted Express parsing (moved, not rewritten). +- `messaging/inbound/baileys-socket.adapter.js` — new Baileys `messages.upsert` listener. +- Both normalize into one internal shape (`{ from, type, text, mediaId, buttonReplyId }`) before handing off + to the existing dispatch logic — the same normalized shape any future driver's adapter must produce, + which is what keeps the registry genuinely N-channel-shaped instead of assuming everything looks like a + webhook. **This is a bounded extraction**, not a rewrite of the 1700-line handler — just enough surgery to + give both drivers a parallel entry path into the same dispatch code. + +## 2. Sandbox flow (Baileys is v1's only sandbox driver; sandbox itself isn't Baileys-specific) + +New script `bot/scripts/setup/baileys-pair.js`: starts a minimal `makeWASocket()` client, prints a terminal +QR (via `qrcode-terminal`, a Baileys peer dependency — no system package needed), waits for the user to +scan it from their own WhatsApp app (Linked Devices), persists auth state to +`CHANNEL_STATE_DIR/baileys/` (see §1), exits 0 on success. + +Proposed env vars, matching `.env.template`'s existing comment conventions: +``` +CHANNEL_DRIVER=baileys # meta | baileys | (future: slack, telegram, ...) — default baileys +CHANNEL_STATE_DIR=.channel-state # shared root for every driver's local state — see §1 +``` +No `WHATSAPP_TOKEN`/`PHONE_NUMBER_ID`/`WABA_ID`/`WEBHOOK_VERIFY_TOKEN` required for `baileys` — §6 makes +these conditional on `CHANNEL_DRIVER=meta`. The whole `CHANNEL_STATE_DIR` tree must be gitignored; it grants +live account access, same sensitivity class as a token. + +## 3. Production flow (Meta) + +Reuses the existing Meta setup steps in [`SETUP.md`](../../SETUP.md) / [`whatsapp.md`](whatsapp.md) as-is — +no reinvention. What's genuinely new: an explicit `CHANNEL_DRIVER=meta` line (today Meta is implicit/only +option) and `doctor` gating its live Graph-API probe on that value. The wizard's production branch collects +the same 4 values SETUP.md already documents, then hands off unchanged to `run-full-setup.js` for +Flow/template registration and the existing Railway deploy docs. + +## 4. Graduation path — `rumi graduate` + +Because users are keyed by `phone_number`, not WABA/channel, conversation history, registration, and +coaching sessions already carry over — no data migration needed by design. The one real caveat to surface +in the tool's output: the sandbox number is the operator's own personal WhatsApp number, while the +production Meta number is normally *different* — existing sandbox testers must be told to message the new +number; there's no server-side redirect possible. + +`rumi graduate [--to=meta]` (a subcommand of the `rumi` CLI, see §5 — implemented in +`bot/scripts/setup/graduate.js`) — the target is a `--to=` argument, looked up in the same +`channel-registry.js` from §1, not hardcoded to Meta, defaulting to `meta` since it's the only +`PRODUCTION_TIER_DRIVERS` entry that exists in v1 (so plain `rumi graduate` with no flag is the common +case). A future `--to=slack` follows the identical shape once a Slack driver is ever promoted to that +allowlist. Steps, doing what's actually automatable and clearly flagging what isn't: +1. Refuse to run if `CHANNEL_DRIVER` already equals the target (idempotency guard). +2. Prompt for / confirm the target driver's required vars (reusing the same collection code path as the + wizard's per-driver branch from §5 — no duplicate prompt logic). +3. **Live-validate** them with the target driver's own probe (for `meta`, the real Graph API call + `doctor.js` already does) before touching any config — fail loud, change nothing, if the credentials + don't work. +4. Flip `CHANNEL_DRIVER=` in `.env` (in place, preserving every other line — treat `.env` as + append/patch, never regenerate). +5. Invoke the existing `run-full-setup.js` to register Flows/templates now that the target channel supports + them (Meta-specific step today; a future driver would define its own equivalent). +6. Rename `CHANNEL_STATE_DIR//` → `CHANNEL_STATE_DIR/.retired/` (don't + delete outright — recoverable if something's wrong) and log that it's no longer read. Because every + driver's state lives under the one shared root (§1), this step is the same one line of code regardless + of which driver is being retired. +7. Print a **manual checklist** for what genuinely cannot be automated from inside the repo: for a `meta` + target, creating/verifying the Meta Business App, Meta's app-review process, and pointing the Meta + webhook at the deployed URL in the Meta developer console. + +## 5. Two-layer CLI: `install.sh` + `rumi ` + +The same two-layer split Hermes uses (`setup-hermes.sh` → `hermes `), adapted for a cloned/forked repo +rather than a globally-curl-installed package. Layer 1 is mechanical and asks nothing about your accounts; +layer 2 asks everything and touches no dependencies. Keeping them apart is what makes each independently +re-runnable. + +**Layer 1 — `install.sh`** (`./install.sh`, once): Node ≥18 / npm / git check, `npm install` at the root and +in `bot/`, `.env` created from the template only if absent, `npm link` so a bare `rumi` works (falling back +to a printed `node bin/rumi.js` when npm lacks permission), then it offers to run `rumi setup` immediately — +which is the whole point of the split being invisible to a first-time user. + +**Layer 2 — `rumi `** (`bin/rumi.js` → `bot/scripts/setup/`): `setup`, `status`, `doctor`, `pair`, +`graduate`. + +### What the wizard is actually optimising for + +The audience is someone at a school or an NGO who was told "you can run this yourself" — not a contributor +to this repo. Four principles follow, and between them they account for most of the code: + +1. **Nothing is asked by its variable name.** The question is "where does Rumi keep its memory", not + `SUPABASE_URL`. Env keys are how `.env` stores an answer, not vocabulary a user should have to learn. + Enforced by a test that asserts the channel question and the Meta prompts never mention their env vars. +2. **Every value is checked while the person who typed it is still there.** Each step runs the *same* + `doctor.js` probe the diagnostic uses, so "configured" and "working" cannot drift apart. A key that is + merely present tells you nothing; its failure surfaces hours later inside a feature with no indication + which of eight values was wrong. +3. **Progress is saved per step, not at the end.** Ctrl+C is a legitimate way to leave — the browser tab for + the next credential is usually the reason. Quitting must never cost work already done. +4. **Anything already working is not asked about again.** Re-running on a configured deployment takes + seconds, changes nothing, and leaves `.env` byte-identical. `--reconfigure` opts back into being asked. + +### Field-shape validation: the real cost centre + +`validators.js` exists because the expensive setup failures are not typos, they are *pasting the wrong +thing* — a value that is perfectly well-formed for what it actually is, so no presence check objects and the +error arrives far from its cause. The ones caught, each with the specific correction rather than "invalid": + +| Mistake | Why nothing else catches it | +|---|---| +| Supabase **anon** key instead of **service_role** | Both are JWTs starting `eyJ`, on the same page, indistinguishable by eye. The anon key cannot see past RLS, so the bot starts cleanly and behaves as if the database were empty. Decoding the token's `role` claim settles it. | +| A phone **number** in `PHONE_NUMBER_ID` | Meta wants their internal 15–17 digit id; Graph answers "Object with ID does not exist", naming neither the field nor the mistake. | +| Another vendor's `sk-…` in `OPENROUTER_API_KEY` | Every AI provider issues one and they look alike in a terminal. The validator names which vendor's key it recognised. | +| The Supabase dashboard URL instead of the API URL | Both are URLs containing "supabase". | +| Upstash's `https://` endpoint as `REDIS_URL` | Their console shows both; only one is the TCP address. | + +Validators may also *clean* input (strip a trailing slash, wrap a bare `host:port`) rather than asking +someone to paste tidily. + +### The five steps + +1. **Database.** URL + service key (masked), live probe, then the schema. Rumi's tables are created inline + via the existing `bootstrap-db.js`. The unavoidable manual detour is named rather than glossed over: + Supabase exposes no API for arbitrary SQL, so the `exec_sql` helper the schema is applied through has to + be pasted in once. `db-setup.js` distinguishes the three states a project can be in — already set up / + helper missing / ready for the schema — because "no tables yet" and "no way to create them" look + identical from outside and need opposite instructions. When the helper is missing the wizard prints the + two lines and links straight to *that project's* SQL editor, derived from the API URL. +2. **AI.** OpenRouter key (masked), then the probe that checks the balance as well as the key. A valid key + with no credit is treated as a question ("carry on and add credit later?"), not a rejection — re-asking + for a key that is perfectly fine would be nonsense. +3. **Redis.** Offers to start a container locally when a Docker daemon is reachable (reusing the container + from a previous run rather than dying on "name already in use"), otherwise takes any address — managed, + self-hosted, or local. Live `PING` either way. +4. **Optional abilities.** Described by what a teacher would notice, not by vendor product name, and + defaulting to *skip*: Rumi works without all of them, and the fastest route to a working bot is not + collecting five more keys. A multi-key extra (Azure needs a key and a region) is only stored when every + key is given — half of it configured is a feature that reports itself available and then fails. +5. **WhatsApp.** The plain-language channel question, then either QR pairing inline (with the caveats stated + *before* the code appears: it is a linked device on a personal account, it can read your chats, use a + spare number if that matters) or Meta's four values with their on-page names and a live Graph check. + +Closing screen: a readiness table naming services by what they do, which optional abilities are on, which key +would switch each remaining one on, the one command that starts Rumi, and what to send it first. + +### `rumi status` versus `rumi doctor` + +Doctor answers *is each service reachable* — a checklist for when something is broken. Status answers the two +questions someone actually has after setup and that no credentials checklist contains: **is Rumi running** +(read from the connection module's own instance lock, rather than a second pid file that could disagree with +it) and **which WhatsApp account is it answering as** (read from the stored session, so it answers even when +Rumi is down). Both render from the same `runDoctor()` result, so they cannot disagree about facts. + +### Terminal presentation + +`ui.js` and `prompt.js` hold everything visual and everything input, so all five commands look like one +product. Two rules there are load-bearing rather than cosmetic: colour switches itself off when stdout is not +a TTY (so piped logs and Jest's captured console stay plain), and widths are computed from *printed* width — +escape codes free, emoji two cells — because a box sized by string length goes ragged the moment a line +contains either. Secrets are read 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 restores the terminal (raw mode off, cursor back) before every command's own goodbye. + +**Relationship to `.claude/skills/setup/SKILL.md`**: the skill now points at these commands for every +mechanical step and keeps only the agent-specific value — deciding which path the user is on, explaining +*why* when someone stalls, and the production steps the wizard deliberately does not cover (hosting, the Meta +webhook, Flow registration, the background worker). This closes the gap that motivated the work — a +non-agent user previously got a strictly worse, manual-only experience — without maintaining two step +sequences that drift apart. + +## 6. Feature-gating changes (must preserve the presence-based, no-tier-system philosophy) + +`bot/shared/config/feature-availability.js`'s `REQUIRED_VARS` is currently a flat array of 8, 4 of which are +the WhatsApp/Meta vars, with no channel concept at all. Change, preserving the file's own stated philosophy +("no tier system and no master enable flag"): + +- Split `REQUIRED_VARS` into an always-required core (`SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, + `OPENROUTER_API_KEY`, `REDIS_URL`) plus a `CHANNEL_REQUIRED_VARS` map, keyed by driver name from the same + `channel-registry.js` in §1: + `{ meta: [WHATSAPP_TOKEN, PHONE_NUMBER_ID, WEBHOOK_VERIFY_TOKEN, WABA_ID], baileys: [] }` — selected by + `CHANNEL_DRIVER`, structurally identical to how `QUEUE_DRIVER` is already sanctioned in `bot/CLAUDE.md`, + just a second selector, not a new gating mechanism. **This map is the extensibility point**: adding Slack + later is `slack: [SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET]` — one line, no restructuring of this file or of + `doctor.js`'s logic. +- **Backward-compat inference is required, not optional**: existing deployments never set + `CHANNEL_DRIVER`. If unset and the 4 Meta vars are already present → infer `meta`; if unset and absent → + default `baileys`. Without this, upgrading the platform would silently reclassify live Meta deployments + as sandbox-tier and break them. +- `doctor.js`: skip the Meta Graph probe cleanly (`status: 'skip'`, not a failure) when + `CHANNEL_DRIVER` is any non-`meta` driver; add a `baileys`-specific offline probe (does + `CHANNEL_STATE_DIR/baileys/` contain a valid session?) — driver-specific probes are looked up the same + way `CHANNEL_REQUIRED_VARS` is, so a future driver adds its own probe function rather than an `if/else` + branch in `doctor.js` itself. +- `.env.template`: add a "Channel selection" block near the top (`CHANNEL_DRIVER`, `CHANNEL_STATE_DIR`), and + annotate the existing WhatsApp block "Required only if `CHANNEL_DRIVER=meta`." + +## 8. WhatsApp Flows, degraded to a conversation + +The original plan (§1) said Flow-dependent features would simply be *unavailable* on sandbox: never set a +`*_FLOW_ID`, let the existing unset-ID guards take over, and have `sendFlow()` log a no-op. Running the +sandbox proved that wrong in the most basic way — **a feature that is unavailable but pretends otherwise +looks broken, not unconfigured**: + +- `/reading test` did `if (flowSent) {...} else { throw new Error('Failed to send WhatsApp Flow') }`, and + the catch answered *"Sorry, something went wrong starting the reading test."* +- `/settings` answered *"Settings are not available yet."* +- `/video`'s picker was gated behind `STUDENT_VIDEOS_FLOW_ID`, so the entire imported content library was + unreachable — the ID can only exist once a Meta Flow has been published. +- Class setup answered *"class setup is not available right now"*, which in turn made `/quiz` impossible, + because a quiz needs a class to send to. + +That is most of the product, on the tier whose whole purpose is *"check the thing works before committing +to Meta's app review"*. + +### The seam: an endpoint is not a Flow + +The key observation is that a Meta Flow is only a **renderer**. Every data-exchange Flow in +`bot/shared/routes/*-endpoint.js` already holds all of the behaviour behind one uniform shape: + +``` +INIT -> { screen, data: { , ...values } } +data_exchange(screen, data) -> { screen, data: { , ... } } | { data: { error: { message } } } +``` + +The DB queries, the validation, the actual video send, the preference write — all of it lives in those +functions. So the sandbox does not need a second implementation of each feature; it needs a **second +renderer**. That is `messaging/endpoint-text-flow.js`: it asks one question per screen field, resolves the +reply by number *or* name, accumulates `screenData` exactly as a Flow client would, and calls the very same +endpoint functions. A bug fixed in an endpoint is fixed for both channels, and a new Flow degrades to text +by declaring a config rather than by writing code. + +Mapping a Flow definition to a config: + +| Flow concept | Config field | +|---|---| +| screen | `stage.screen` (the value passed to `data_exchange`) | +| a form field | one entry in `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`) | + +### Navigate-style Flows: synthesise the webhook instead + +Two Flows have no endpoint — their submission arrives as a single `nfm_reply` webhook. For those +(`reading-assessment`, `class-setup`) the text flow collects the same fields and then **synthesises that +exact webhook shape**, so `whatsapp-bot.js`'s `nfm_reply` dispatch and `flow-response.handler.js` run +completely unmodified. The field *names* are therefore a contract with `flow-response.handler.js` and +`utils/flow-type-detector.js`, and `tests/messaging/text-flow-definitions.test.js` pins them against those +real consumers so a rename fails in CI rather than on a live deployment. + +Class setup is worth a note: its Meta Flow is an endpoint-driven **loop** (one screen per student, "Add & +Continue" repeatedly), which suits a form but not a conversation. Attendance also accepts a navigate-format +submission (`class_name` + `student_list`, parsed one-student-per-line), and in chat that is strictly +better — the teacher pastes the whole roster once. So the text flow targets that shape instead of +replicating the loop. + +### Rules the engine follows, each learned from a live failure + +- **Answer by number OR name.** Demanding a number is unrealistic; people type "Urdu", or the half of a + `"Chapter · Title"` label that actually names the video. Ambiguity resolves to *nothing* rather than a + guess (`pending-options.js#resolveSelection`). +- **A digits-only reply that is out of range falls back to an exact name match** — a teacher's classes are + literally named `4 - B` and `5`, so replying `5` to a two-item list means the class, not item five. +- **A command always wins over a pending question.** Not every command starts with `/` — `add class`, + `attendance` and `register` are plain phrases, and a free-text step accepts *any* text. Without this, + typing "add class" while a roster question was open created a class whose only student was named + "add class". +- **Two strikes and the flow lets go.** One unmatched reply re-asks; a second abandons the flow so the + message reaches normal handling. This self-heals without the adapter needing to know every command. +- **A flow never dead-ends.** A step with no options (an endpoint error, an empty library) ends the flow + and shows the endpoint's own explanation, instead of parking the user on an unanswerable question. +- **The endpoint response is carried in flow state, not recomputed.** Replaying earlier `data_exchange` + calls to render a later step would re-fire real side effects — student-videos' final screen *sends a + video*. + +On Meta none of this is reached: the native Flow is better UX and is what production users get. +`sendFlow()` prefers the real Flow whenever a `*_FLOW_ID` exists, and callers now branch on its return +value rather than on the presence of an env var. + +## 7. Out of scope for v1 + +- Actually shipping any driver beyond `meta` and `baileys` — Slack/Telegram/etc. get a registry entry, a + `channel-capabilities.js` entry, and a `templateRenderMode: 'slash-command'` path documented and ready to + implement against, but no working driver code in v1. The point of §1's design is that adding one later is + a pure addition (new registry key + new service file + new capability entry), never a restructuring of + `messaging/index.js`, `channel-registry.js`, `feature-availability.js`, or any call site. +- The `'slash-command'` template-render mode itself isn't implemented (there's no Slack/Telegram driver to + render into) — only the `'native-template'` mode (meta + baileys) ships in v1. +- Meta-template-specific methods with no Baileys equivalent (`sendTemplate`, `sendFlow`, both carousel + methods and their payload builders) — these need the channel-agnostic template registry from §1, still not + built, and log "not supported on this channel" rather than being implemented. Everything else (sends, + reactions, typing indicators, stickers, interactive buttons/lists as numbered text) is implemented. +- Headless/hosted sandbox with reconnect-storm handling — v1 sandbox targets local/dev quick-start, not + production-hosting Baileys at scale. +- Full rewrite of the 1700-line webhook handler — only the bounded inbound-adapter extraction from §1. +- Pairing-code / deny-by-default first-contact security model (a genuinely good Hermes-inspired idea, but a + separate feature from channel onboarding). +- Multi-tenant / multi-org-per-deployment channel routing — this design is single-channel-per-deployment, + same as today. +- `rumi status` as a distinct command — v1 gives `rumi doctor` the CLI-under-one-name treatment; a separate + lighter-weight `status` view is a nice-to-have if the two ever need to diverge, not built now. +- Global/system-wide `rumi` installation (a curl one-liner like Hermes's) — v1's `rumi` is repo-local + (`bin/rumi.js`, optionally `npm link`ed); a project is cloned/forked per deployment, not installed once + globally, so there's no cross-repo CLI to distribute yet. + +## Implementation status + +Build order, and what's actually landed against it (branch `feat/channel-driver-onboarding`, uncommitted): + +1. **✅ Done** — §1's `messaging/` module: `channel-registry.js` (the driver map + production-tier + allowlist), `meta-channel.service.js` (the mechanical lift of the old `whatsapp.service.js`, byte-for-byte + except the relative-path adjustments the new directory depth requires), `index.js` (the selector), and + `whatsapp.service.js` reduced to a one-line facade over it. Went through the `cross-agent-safety` checklist + given it's the highest-blast-radius file this design touches. +2. **✅ Done** — §6's `feature-availability.js` / `doctor.js` changes: `REQUIRED_VARS` is now core-only (4 + vars), `CHANNEL_REQUIRED_VARS` + `resolveChannelDriver` + `requiredVarsFor` added, `doctor.js` reports the + resolved channel and warns on both an unrecognized `CHANNEL_DRIVER` value and the Baileys caveat below. +3. **✅ Done, live-verified** — §2's real Baileys driver: + - `baileys-connection.js` — the persistent-socket manager (`makeWASocket`/`useMultiFileAuthState`/ + `fetchLatestBaileysVersion`, all lazily required so nothing touches the real `baileys` package until a + connection is actually opened), with a minimal (non-storm-hardened, by design — see §7) auto-reconnect. + - `baileys-channel.service.js` — real sends for text, reactions, typing indicators (presence updates), + images/documents/audio/video/stickers, and the URL-based senders (download from R2 first). An inbound + media bridge (`_cacheIncomingMedia`) works around Baileys having no "fetch media by id later" API the + way Meta does. Interactive buttons/lists/language-selection/style-menus render as numbered plain text + (Baileys' native button/list messages are unreliable across current WhatsApp clients). `sendTemplate`, + `sendFlow`, and the carousel methods stay honest stubs — no Baileys equivalent exists without the + channel-agnostic template registry mentioned in §1, which is still not built (see §7). + - `bot/scripts/setup/baileys-pair.js` — the QR pairing script. + - `messaging/inbound/baileys-socket.adapter.js` — translates a Baileys `messages.upsert` event into the + same shape `validators.js#validateWebhookMessage` produces from a real Meta webhook, then calls + `whatsapp-bot.js`'s `handleWebhookPost` directly with a synthetic `{req, res}` pair. Covers text, image, + audio/voice, and document messages — Meta-only interaction types (Flow submissions, interactive + buttons/lists) have no Baileys equivalent and simply never trigger under this driver. + - `whatsapp-bot.js` itself only changed by a verified-zero-diff mechanical extraction (the inline + `app.post('/webhook', ...)` handler became the named `handleWebhookPost` function so the adapter above + could call it) plus a new `wireBaileysInboundIfSelected()` — the ~1000 lines of existing Meta dispatch + logic were never touched. + - **Live-verified**: real QR pairing, all 11 non-stub outbound methods, and a full inbound round trip + (text in → dispatch → AI reply → delivered) against two real WhatsApp accounts. Inbound image receipt + verified structurally (download → cache → dispatch into the real handler); voice/document dispatch is + unit-verified and code-reviewed but a *complete* reply for them needs `SONIOX_API_KEY` (voice + transcription) or R2 credentials, neither configured in this environment. +4. **✅ Done, live-verified** — §5's CLI: `install.sh` (tool check, dependency install, idempotent `.env`, + `npm link`, then offers to run the wizard), `bin/rumi.js` (`setup` / `status` / `doctor` / `pair` / + `graduate`, with a help screen built from the command table), and in `bot/scripts/setup/`: `ui.js` (the + presentation layer), `prompt.js` (masked secrets, arrow-key menus, validated re-asking), + `validators.js` (the field-shape checks above), `db-setup.js` (the three database states + the SQL-editor + link), `fields.js` (the human copy for Meta's credentials and the optional abilities, shared with + `graduate`), `link-whatsapp.js` (pairing, shared between `rumi setup` and `rumi pair`), `summary.js` (the + readiness view, shared with `rumi status`), `status.js`, and `interactive-setup.js` (the five steps). + **Live-verified** in a real terminal: arrow-key selection and masked entry through a pty; a fresh-`.env` + run showing the anon-key and dashboard-URL rejections with their corrections, a failed live probe + re-asking with the previous value offered back, and Ctrl+C leaving a saved partial `.env`; and a re-run on + a fully configured deployment finishing in two keypresses with `.env` byte-identical (verified by + checksum). +5. **✅ Done** — §4's `rumi graduate --to=meta`: collects the target driver's vars (prefilling from any + existing non-placeholder `.env` value), live-validates them via `doctor.js`'s own probe *before* touching + `.env`, patches `CHANNEL_DRIVER` in place, retires the outgoing driver's local session + (`CHANNEL_STATE_DIR/` → `.retired`), and prints the manual checklist for what can't be + automated (Meta Business App creation/review, webhook config, Flow/template registration). + +6. **✅ Done** — §8's text-flow degradation: `messaging/text-flow.js` (the engine), + `endpoint-text-flow.js` (the builder that drives a real Flow endpoint over chat) and + `text-flow-definitions.js` (`student-videos`, `settings`, `reading-assessment`, `class-setup`). Callers + in `text-message.handler.js` and `quiz-intent-router.service.js` now branch on `sendFlow()`'s return + value instead of on the presence of a `*_FLOW_ID`. + +### What a watched fresh-clone run found + +One person, one fresh clone (`.env` deleted, WhatsApp session retired, bot stopped), `./install.sh` → +`rumi setup`, recorded end to end. **6 minutes 13 seconds**, including dependency install and a real QR +pairing — against a 30-minute target. Seven problems, all fixed and pinned by tests: + +| What the user saw | Cause | Fix | +|---|---|---| +| `✔ Linked`, then three lines later `WhatsApp — not linked yet, run rumi pair` | Pairing succeeded but the *number* was unknown, and the readiness line keyed off the number rather than the link. The number was unreadable because `events.emit('open')` fires synchronously just before `getSocket()` resolves, so the `.then()` capturing the socket had not run — and Baileys then replaces that socket anyway during its post-pairing "restart required". | Read the number from the stored session (`creds.json`), falling back from the socket. Render from the *link* state, never from the presence of a number. | +| ~15 lines of raw Baileys JSON immediately above and below the QR code, including the pairing handshake | `makeWASocket` was given no `logger`, so Baileys used its own at info level | Pass a silent logger when `RUMI_CLI=1`; the server keeps its diagnostics | +| `⚠️ Logging DISABLED - dataset=MISSING` on a screen where nobody asked about observability | Axiom's startup notice, unconditional | Suppressed under `RUMI_CLI` | +| "Picking up from last time — 2 of 3 core services are already configured" **on a clone that had configured nothing** | `.env` is copied from `.env.template`, which ships working-looking values (`redis://localhost:6379`, `https://your-project.supabase.co`). Anything non-`CHANGEME` counted as an answer. | `isProvided()` — a value equal to the template's own is a suggestion, not configuration | +| `› Project URL [https://your-project.supabase.co]:` — pressing Enter would have accepted a placeholder | same | Prefills come from `isProvided()` | +| `Checking the Redis you already have… ✘ Connection is closed.` about a Redis the user had never mentioned | same, plus ioredis's error naming neither the address nor the reason | A template suggestion is probed *silently* and falls through to the prompt; the probe now reports `nothing answered at redis://…` | +| The Redis step asked for an address and explained the format, but never said how someone with **no** Redis and **no** Docker was supposed to get one — and Redis is required, so that is a blocked setup | The where-to-get-one guidance only existed as the Docker menu option | When Docker is absent, print the Upstash / Docker / existing-server routes | + +Two things the run confirmed rather than contradicted: the masked secret entry and arrow-key menus behave +correctly under a real TTY, and the closing screen's "message + and try /menu" is the right shape — +it just needed the number to actually be there. + +Still not asked about by the wizard: `DEFAULT_REGION`. A fresh `.env` takes the template's `default`, which is +fail-open and works, but a deployment that had set a region silently loses it on a from-scratch re-setup. +Flagged rather than fixed — adding a sixth question for a value most deployments never change is the wrong +trade, but it belongs in the manual checklist. + +### The launch-directory bug class, found by using it + +The watched run ended with the user doing the obvious next thing — `cd bot && npm start`, exactly as the +closing screen told them to. That one instruction exposed a family of bugs that had been latent since before +this work: **paths resolved against `process.cwd()` rather than against the repo.** + +| Resolved against cwd | What it did from `bot/` | +|---|---| +| `require('dotenv').config()` in `whatsapp-bot.js` | looked for `bot/.env`, loaded nothing, and the bot aborted with "Missing REQUIRED env var(s): SUPABASE_URL, …" on a fully configured deployment | +| the same in `bin/rumi.js` and `doctor.js` | `rumi doctor` reported **every** service as "not configured", with the missing-key hints for all of them | +| `authDir()` in `baileys-connection.js` | used `bot/.channel-state` — an *empty* folder — so Baileys registered a **second WhatsApp device** and re-synced from scratch, endlessly. Both devices were live on one account (`:13` at the repo root, `:14` under `bot/`), and WhatsApp then invalidated the first with a 401. | +| `statePath` in `doctor.js`, `retireOutgoingDriverState` in `graduate.js` | reported "no Flows registered" for a deployment that had them; would have retired the wrong folder and left the live session in place | + +All four are now repo-anchored, with tests that pin the anchoring rather than the string. The last one is the +serious one: it is a data-loss bug in the only piece of state Rumi cannot regenerate, and it fires on the most +natural command a person could type. + +`rumi start` was added in the same pass — partly because the user asked for it, and partly because it makes +the launch directory structurally irrelevant instead of merely documented. It also forwards SIGINT/SIGTERM to +the bot: without that, killing the launcher orphaned a bot still holding the session lock, and the next start +refused to attach while blaming a pid with no visible owner. + +**Two bugs this pass introduced and caught by running the thing:** + +- `logger: isCli ? quiet : undefined` on `makeWASocket`. Reads as a no-op for the server; is not. Baileys + merges config over its defaults, so an explicit `undefined` *replaced* their default logger and the next + `logger.child()` threw. The bot booted, reported every service healthy, and had no WhatsApp connection at + all. Now set as a separate key, and asserted on the config actually passed rather than on the source text. +- A test that exercised `retireOutgoingDriverState` with the real `.channel-state`, and by working exactly as + designed renamed the developer's live WhatsApp session. Now uses a test-only directory name: a test must not + be able to do that even when it passes. + +### Three UX changes from watching, not from reasoning + +- **Each step clears the screen** and reprints a tick per completed step. Without it every prompt landed on + the terminal's bottom line with its explanation scrolled above — the thing to read and the thing to type at + opposite ends of the window. +- **The sandbox caveats now name what is Meta-only** (tap-through forms, approved templates, picture-menu + carousels) and say to graduate for the full experience, rather than only warning about the account risk. +- **`cd bot && npm start` is gone from every screen and doc**, replaced by `rumi start`. + +### Bugs the feature pass found — all pre-existing, most affecting Meta too + +Running the sandbox as a user surfaced a set of failures that no unit test caught, because each one was +either wrapped in a try/catch that made it look transient, or was a missing method that only threw at +runtime. Listed because they are the substance of "make the sandbox usable", and because several of them +mean the feature was broken on **every** deployment, not just this one: + +| Bug | Consequence before the fix | +|---|---| +| `redisService.setexWithCeiling` never existed, called from 10+ places across the quiz subsystem | No quiz could be delivered on any deployment, ever | +| `redisService.setNX` never existed — the idempotency claim at the top of every image analysis, un-caught | Every inbound image answered with the generic error | +| `quiz_class_*` list replies had no handler, though `continueWithClass()` is documented as "Called from whatsapp-bot.js list_reply handler" | `/quiz` dead-ended at the class picker | +| Five services built their own OpenAI client keyed on `OPENAI_API_KEY`, contradicting the documented single entry point | Quiz generation and the whole pic-to-LP pipeline failed on an OpenRouter deployment | +| `quiz_sessions` was missing six columns — present in the `CREATE TABLE`, absent from the column-reconcile block | Accepting a post-video quiz offer failed on any upgraded database | +| The reading assessment uploaded audio to R2 unconditionally, then downloaded it again in the queued analysis step | Recording a student read produced "🚨 CRITICAL: audio processing failed" wherever no bucket exists | +| Passage generation treated its R2 archival upload as required | A passage already rendered to disk was reported as "error generating the passage" | +| A TTS failure propagated out of the voice handler | A transcribed, answered voice note was replaced by "sorry, an error occurred" | +| `/quiz` told teachers to type "set up class" — a phrase no detector recognised | Following the bot's own instruction fell through to general AI chat | +| The pic-LP router pre-claimed the image idempotency key that its own batch coalescer then needed | No reply at all for any non-textbook image | +| Scheduling a quiz *report* threw when no queue was configured, after delivery had succeeded | Students received the quiz; the teacher was told it had failed | +| `.limit(1).single()` on six "find the newest, if any" lookups | A scary `Cannot coerce the result to a single JSON object` on the normal empty path, masking real errors | +| `doctor.js` probed only that the OpenRouter key authenticates | A valid-but-unfunded key reported `✅ … HTTP 200` and "all required services configured" while every substantial call returned 402 | +| Two processes could share one Baileys auth folder | WhatsApp invalidated the session itself (`Stream Errored (conflict)`), requiring a human to re-pair | +| A QR issued when credentials already existed was treated as a pairing opportunity | Endless QR reissue every ~20s — the mechanism behind this project's repeated "can't link new devices" rate limiting | +| The reading-assessment report PDF was uploaded to R2 unconditionally | A report that had already been rendered marked the assessment `failed` and the teacher got nothing | +| The failure message told the teacher "Our team has been notified" | False on a self-hosted deployment — nobody is notified, and it replaces an actionable instruction with a fiction | +| One reading failure produced three apologies (analysis service, reading branch, outer catch) plus a spoken one | The teacher is told about the same problem up to four times, the last in Urdu regardless of her language | +| Feature intro video URLs interpolated an unset `R2_PUBLIC_URL` into a relative path | The bot asked "Want to see how? 🎥" and could never deliver a video if she said yes | +| Nothing removed the locally-stored recording or report | Every assessment left an `.ogg` and a `.pdf` behind forever — a disk leak, and stale copies of a child's voice | + +Four now carry conformance guards so they cannot silently return: +`tests/setup/no-undefined-redis-methods.test.js` (every `redisService.()` resolves), +`tests/setup/llm-single-entry-point.test.js` (no service builds its own chat-completion client, with a +justified allow-list for the audio-only endpoints), plus unit coverage for the two new Redis methods and the +no-object-storage audio path. + +The wizard also now sets `QUEUE_DRIVER=bullmq` for a sandbox: the template default is `sqs`, which needs an +AWS account, so a sandbox inherited a queue it could never use. + +All 163 test suites / 1837 tests pass (`npm test`). Not done: everything already listed in §7's out-of-scope +list (Slack/Telegram drivers, the `'slash-command'` template mode, `rumi status`, global `rumi` install, +pairing-code security model, multi-tenant routing) — none of that changed. Also still open: the 12-hour quiz report has not been +observed (it is a timer), pronunciation scoring needs `AZURE_SPEECH_KEY`, and video *generation* needs +`KIE_API_KEY` — the pre-made library covers `/video` without it. diff --git a/infrastructure/CLAUDE.md b/infrastructure/CLAUDE.md index 5cdef12..aeaa414 100644 --- a/infrastructure/CLAUDE.md +++ b/infrastructure/CLAUDE.md @@ -6,7 +6,7 @@ | Path | What's there | |------|--------------| -| `supabase/00_complete-schema.sql` | The single fresh-install artifact — 73 tables, functions, triggers, + an idempotent column-reconcile section at the end | +| `supabase/00_complete-schema.sql` | The single fresh-install artifact — 76 tables, functions, triggers, + an idempotent column-reconcile section at the end | | `supabase/01_rls-policies.sql` | Row-level security policies | | `supabase/02_seed-data.sql` | Reference data (reading benchmarks) + `region_features` default row (fail-open gating) | | `supabase/migrations/V*.sql` | Versioned upgrades, tracked in `schema_versions` | diff --git a/infrastructure/supabase/00_complete-schema.sql b/infrastructure/supabase/00_complete-schema.sql index 3ccdb33..0a2d46b 100644 --- a/infrastructure/supabase/00_complete-schema.sql +++ b/infrastructure/supabase/00_complete-schema.sql @@ -3858,6 +3858,50 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}'; -- quiz_sessions: idle-reminder cron flag. ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS idle_reminder_sent BOOLEAN DEFAULT FALSE; +-- quiz_sessions: the video-quiz identity set. These already appear in the +-- table definition above, which is exactly why they were missing on every +-- upgraded database: "CREATE TABLE IF NOT EXISTS" is a no-op on an existing +-- table, so columns added to a definition only ever reach a FRESH install. +-- Found live — a teacher accepting the +-- post-video quiz offer got "Sorry — I couldn't start that quiz", from +-- PostgREST's "Could not find the 'invited_by_student_id' column of +-- 'quiz_sessions' in the schema cache". +ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS student_name TEXT; +ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS student_class TEXT; +ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS share_code_id UUID REFERENCES quiz_share_codes(id) ON DELETE SET NULL; +ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS invited_by_student_id UUID REFERENCES students(id); +ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'roster'; +-- A solo run by a teacher, and a child arriving via a share link, have no +-- roster row. The original table made student_id mandatory. +ALTER TABLE quiz_sessions ALTER COLUMN student_id DROP NOT NULL; + +-- The two CHECKs that ship inline in the CREATE TABLE, added here for the same +-- upgraded-database reason. Guarded by name: ADD CONSTRAINT has no IF NOT EXISTS. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'quiz_sessions'::regclass AND conname = 'quiz_sessions_source_check') THEN + ALTER TABLE quiz_sessions ADD CONSTRAINT quiz_sessions_source_check + CHECK (source IN ('roster', 'video_solo', 'share_link')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'quiz_sessions'::regclass AND conname = 'quiz_sessions_has_identity') THEN + ALTER TABLE quiz_sessions ADD CONSTRAINT quiz_sessions_has_identity + CHECK (student_id IS NOT NULL OR user_id IS NOT NULL OR student_name IS NOT NULL); + END IF; +END $$; + +-- Re-declared after the columns exist: the CREATE INDEX statements next to the +-- table definition run BEFORE this reconcile, so on an upgraded database they +-- hit "column does not exist" and aborted the apply. +CREATE INDEX IF NOT EXISTS idx_quiz_sessions_user_id + ON quiz_sessions(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_quiz_sessions_share_code + ON quiz_sessions(share_code_id) WHERE share_code_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_quiz_sessions_invited_by + ON quiz_sessions (invited_by_student_id) WHERE invited_by_student_id IS NOT NULL; + -- reading_assessments: abandon-path timestamp. ALTER TABLE reading_assessments ADD COLUMN IF NOT EXISTS failed_at TIMESTAMPTZ; diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3a6a775 --- /dev/null +++ b/install.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Rumi — install. +# +# The mechanical half of onboarding, and only that: check the tools, install +# dependencies, create .env from the template, put the `rumi` command on your +# PATH. It asks nothing about your accounts or credentials — that is `rumi +# setup`, which this script offers to run for you at the end. +# +# The split exists so that re-running either half is safe. This one is +# idempotent (it never overwrites an existing .env); the wizard remembers what +# is already configured and skips it. +# +# ./install.sh +# +# See docs/onboarding/sandbox-production-design.md §5. + +set -euo pipefail + +# ${BASH_SOURCE[0]:-$0} rather than ${BASH_SOURCE[0]}: under `set -u` the bare +# form aborts when the script is piped into bash instead of executed. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colour only when a human is watching, matching bot/scripts/setup/ui.js — a +# piped install log should stay readable. +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + BOLD=$'\033[1m'; DIM=$'\033[38;2;140;152;168m'; GREEN=$'\033[38;2;37;211;102m' + TEAL=$'\033[38;2;125;232;205m'; AMBER=$'\033[38;2;245;176;66m'; RED=$'\033[38;2;239;83;80m' + RESET=$'\033[0m' +else + BOLD=''; DIM=''; GREEN=''; TEAL=''; AMBER=''; RED=''; RESET='' +fi + +step() { printf '%s→%s %s\n' "$AMBER" "$RESET" "$1"; } +ok() { printf '%s✔%s %s\n' "$GREEN" "$RESET" "$1"; } +warn() { printf '%s!%s %s\n' "$AMBER" "$RESET" "$1"; } +note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; } +fail() { printf '%s✘%s %s\n' "$RED" "$RESET" "$1"; exit 1; } + +printf '\n' +printf '%s██████╗ ██╗ ██╗███╗ ███╗██╗%s\n' "$TEAL" "$RESET" +printf '%s██╔══██╗██║ ██║████╗ ████║██║%s\n' "$TEAL" "$RESET" +printf '%s██████╔╝██║ ██║██╔████╔██║██║%s\n' "$GREEN" "$RESET" +printf '%s██╔══██╗██║ ██║██║╚██╔╝██║██║%s\n' "$GREEN" "$RESET" +printf '%s██║ ██║╚██████╔╝██║ ╚═╝ ██║██║%s\n' "$GREEN" "$RESET" +printf '%s╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝%s\n' "$GREEN" "$RESET" +printf '\n%sAn AI teaching companion that lives in WhatsApp%s\n\n' "$DIM" "$RESET" + +# --- 1. Tools ---------------------------------------------------------------- + +step "Checking the tools this needs" + +command -v git >/dev/null 2>&1 || fail "git is not installed. Install it, then run this again." +command -v node >/dev/null 2>&1 || fail "Node.js is not installed. Get version 18 or newer from https://nodejs.org, then run this again." +command -v npm >/dev/null 2>&1 || fail "npm is not installed — it normally comes with Node.js. Reinstall Node from https://nodejs.org" + +NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" +if [ "$NODE_MAJOR" -lt 18 ]; then + fail "Rumi needs Node 18 or newer; this is $(node -v). Update from https://nodejs.org, then run this again." +fi +ok "Node $(node -v), npm $(npm -v), git" + +# --- 2. Dependencies --------------------------------------------------------- + +step "Installing dependencies — a few minutes, and the noisiest part of this" +npm install --no-fund --no-audit +(cd bot && npm install --no-fund --no-audit) +ok "Dependencies installed" + +# --- 3. Settings file -------------------------------------------------------- + +if [ -f .env ]; then + ok "Found your existing .env — left exactly as it was" +else + cp .env.template .env + ok "Created .env (your settings file; it stays on this machine and is never committed)" +fi + +# --- 4. The `rumi` command --------------------------------------------------- + +RUMI_CMD="node bin/rumi.js" +step "Putting the 'rumi' command on your PATH" +if npm link >/dev/null 2>&1; then + RUMI_CMD="rumi" + ok "You can now run 'rumi' from anywhere" + note "(a global npm link; undo it any time with 'npm unlink -g rumi')" +else + warn "Could not add it (this usually means npm needs different permissions)" + note "Not a problem — use 'node bin/rumi.js ' instead of 'rumi '." +fi + +# --- 5. Straight into setup -------------------------------------------------- + +printf '\n%s%s%s\n' "$DIM" "──────────────────────────────────────────────────────────────" "$RESET" +ok "Installed." +printf '\n' +printf ' %sNext:%s connect Rumi to your accounts. Takes about fifteen minutes,\n' "$BOLD" "$RESET" +printf ' and it explains every step as it goes.\n\n' +printf ' %s%s setup%s\n\n' "$TEAL" "$RUMI_CMD" "$RESET" + +# Offered rather than assumed: the wizard needs accounts and keys to hand, and +# someone who ran this on a server at midnight may not have them yet. +if [ -t 0 ]; then + printf ' %sRun it now? [Y/n]%s ' "$DIM" "$RESET" + read -r ANSWER || ANSWER="" + case "${ANSWER:-Y}" in + [Yy]*|"") exec $RUMI_CMD setup ;; + *) note "When you are ready: $RUMI_CMD setup" ;; + esac +fi +printf '\n' diff --git a/package-lock.json b/package-lock.json index f0548a0..305167b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "1.1.0", "license": "Apache-2.0", "dependencies": { - "bullmq": "^5.67.1", "express": "^5.2.1", "ioredis": "^5.9.2", "openai": "^6.16.0" }, + "bin": { + "rumi": "bin/rumi.js" + }, "devDependencies": { "@types/jest": "^29.5.12", "jest": "^29.7.0", @@ -54,7 +56,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -895,84 +896,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1407,7 +1330,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1439,33 +1361,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bullmq": { - "version": "5.67.1", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.1.tgz", - "integrity": "sha512-ELJEAzwzesgFxk29emvnAakqrwdBEhEyfZREPQ8pbG4ALVz/mk/AhfuChzxkFpJ7SfL2qclPHbiUGBZzaqcLvg==", - "license": "MIT", - "dependencies": { - "cron-parser": "4.9.0", - "ioredis": "5.9.2", - "msgpackr": "1.11.5", - "node-abort-controller": "3.1.1", - "semver": "7.7.3", - "tslib": "2.8.1", - "uuid": "11.1.0" - } - }, - "node_modules/bullmq/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1733,18 +1628,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1820,16 +1703,6 @@ "node": ">= 0.8" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3366,15 +3239,6 @@ "yallist": "^3.0.2" } }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3519,37 +3383,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3566,27 +3399,6 @@ "node": ">= 0.6" } }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4473,12 +4285,6 @@ "node": ">=0.6" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -4563,19 +4369,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index f5285e4..4afdf08 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "name": "rumi-platform", - "version": "1.1.0", + "version": "2.0.0", "description": "Rumi - AI Teaching Assistant for WhatsApp. Open-source platform for deploying AI-powered educational chatbots.", "private": true, + "bin": { + "rumi": "./bin/rumi.js" + }, "scripts": { "test": "node tests/run.js", "test:security": "node tests/run.js --testPathPattern=sprint-0", @@ -16,8 +19,14 @@ "bootstrap:db": "node infrastructure/scripts/bootstrap-db.js", "doctor": "node bot/scripts/setup/doctor.js", "setup:flows": "node bot/scripts/setup/run-full-setup.js", + "setup:interactive": "node bot/scripts/setup/interactive-setup.js", + "pair:baileys": "node bot/scripts/setup/baileys-pair.js", + "graduate": "node bot/scripts/setup/graduate.js", "simulate": "node bot/scripts/simulate.js", - "setup": "node bot/scripts/setup/doctor.js" + "setup": "node bin/rumi.js setup", + "status": "node bin/rumi.js status", + "pair": "node bin/rumi.js pair", + "start": "node bin/rumi.js start" }, "keywords": [ "whatsapp", diff --git a/tests/__mocks__/ffmpeg-installer.js b/tests/__mocks__/ffmpeg-installer.js new file mode 100644 index 0000000..b3fd838 --- /dev/null +++ b/tests/__mocks__/ffmpeg-installer.js @@ -0,0 +1,8 @@ +/** + * @ffmpeg-installer/ffmpeg and @ffprobe-installer/ffprobe mock. + * + * Both ship a platform binary and live in bot/node_modules only. Callers use + * just the `.path`, which they hand to fluent-ffmpeg's setters — mocked + * alongside — so a plausible path string is the whole contract. + */ +module.exports = { path: '/usr/bin/ffmpeg', version: '0.0.0-test' }; diff --git a/tests/__mocks__/fluent-ffmpeg.js b/tests/__mocks__/fluent-ffmpeg.js new file mode 100644 index 0000000..9c6722e --- /dev/null +++ b/tests/__mocks__/fluent-ffmpeg.js @@ -0,0 +1,55 @@ +/** + * fluent-ffmpeg mock for the OSS test suite. + * + * fluent-ffmpeg (with its @ffmpeg-installer / @ffprobe-installer companions) is + * a runtime dependency in bot/node_modules but not the root, and CI runs the + * ROOT suite before `cd bot && npm ci` — so any test that reaches + * bot/shared/services/audio.service.js needs this or the suite fails in CI for + * reasons unrelated to the test. + * + * audio.service.js configures the binary paths at import time + * (`setFfmpegPath` / `setFfprobePath` on the module itself) and then builds + * per-file command chains, so the export has to be callable *and* carry those + * setters. Every chain method returns `this` so a chain of any length works; + * `.run()` and `.save()` invoke the registered `end` handler on the next tick, + * which is enough for code that awaits a completion callback. + */ + +function createCommand() { + const handlers = {}; + const command = { + on: jest.fn((event, callback) => { handlers[event] = callback; return command; }), + run: jest.fn(() => { setImmediate(() => handlers.end && handlers.end()); return command; }), + save: jest.fn(() => { setImmediate(() => handlers.end && handlers.end()); return command; }), + }; + + // Chainable no-ops: anything audio.service.js calls to describe a conversion. + for (const method of [ + 'input', 'output', 'inputFormat', 'outputFormat', 'audioCodec', 'audioBitrate', + 'audioChannels', 'audioFrequency', 'videoCodec', 'format', 'duration', 'seek', + 'seekInput', 'size', 'fps', 'noVideo', 'noAudio', 'outputOptions', 'inputOptions', + 'complexFilter', 'audioFilters', 'videoFilters', 'toFormat', 'pipe', 'kill', + ]) { + command[method] = jest.fn(() => command); + } + return command; +} + +const ffmpeg = jest.fn(() => createCommand()); + +// Module-level configuration audio.service.js performs on import. +ffmpeg.setFfmpegPath = jest.fn(); +ffmpeg.setFfprobePath = jest.fn(); +ffmpeg.setFlvtoolPath = jest.fn(); + +// Probing, used to read a recording's duration. +ffmpeg.ffprobe = jest.fn((_file, callback) => { + if (typeof callback === 'function') { + callback(null, { streams: [{ codec_type: 'audio', duration: '1.0' }], format: { duration: '1.0' } }); + } +}); + +ffmpeg.getAvailableFormats = jest.fn((callback) => callback && callback(null, {})); +ffmpeg.getAvailableCodecs = jest.fn((callback) => callback && callback(null, {})); + +module.exports = ffmpeg; diff --git a/tests/cache/redis-missing-methods.test.js b/tests/cache/redis-missing-methods.test.js new file mode 100644 index 0000000..f272be9 --- /dev/null +++ b/tests/cache/redis-missing-methods.test.js @@ -0,0 +1,114 @@ +/** + * setNX and setexWithCeiling — two methods the bot called for a long time + * without them existing. + * + * Both failures were found by running the bot, and both were invisible in the + * code because of how they failed: + * - setexWithCeiling: called from 10+ places in the quiz subsystem, so no quiz + * could be delivered on any deployment (/quiz → pick a class → "Sorry, + * something went wrong"). + * - setNX: the idempotency claim at the top of runImageAnalysis(), NOT wrapped + * in its own try/catch, so EVERY inbound image threw and answered with the + * generic error. + * + * The service is a singleton, so each test re-requires it fresh (same pattern as + * redis-error-throttle.test.js). + */ + +const path = require('path'); + +const SERVICE = path.resolve(__dirname, '../../bot/shared/services/cache/railway-redis.service'); +const LOGGER = path.resolve(__dirname, '../../bot/shared/utils/logger'); +const CONSTANTS = path.resolve(__dirname, '../../bot/shared/utils/constants'); + +/** @param {{available?: boolean, set?: Function, setex?: Function}} opts */ +function loadService({ available = true, set, setex } = {}) { + jest.resetModules(); + const calls = { set: [], setex: [] }; + jest.doMock('ioredis', () => class MockRedis { + constructor() { this.status = 'ready'; } + on() {} + async set(...args) { calls.set.push(args); return set ? set(...args) : 'OK'; } + async setex(...args) { calls.setex.push(args); return setex ? setex(...args) : 'OK'; } + }); + const mockLog = jest.fn(); + jest.doMock(LOGGER, () => ({ logToFile: mockLog })); + jest.doMock(CONSTANTS, () => ({ RATE_LIMIT_MAX: 30, RATE_LIMIT_WINDOW_SECONDS: 60 })); + + if (available) process.env.REDIS_URL = 'redis://localhost:6379'; + else delete process.env.REDIS_URL; + + const svc = require(SERVICE); + return { svc, calls, mockLog }; +} + +afterEach(() => { + process.env.REDIS_URL = 'redis://localhost:6379'; + jest.resetModules(); +}); + +describe('setNX', () => { + it('issues an atomic SET … EX ttl NX and reports the key as claimed', async () => { + const { svc, calls } = loadService(); + await expect(svc.setNX('image:u1:i1', '{"status":"processing"}', 300)).resolves.toBe(true); + expect(calls.set[0]).toEqual(['image:u1:i1', '{"status":"processing"}', 'EX', 300, 'NX']); + }); + + it('reports NOT claimed when the key already exists (Redis returns null)', async () => { + const { svc } = loadService({ set: () => null }); + await expect(svc.setNX('k', 'v', 300)).resolves.toBe(false); + }); + + it('claims the key when Redis is unavailable, so the work is not skipped', async () => { + // Returning false here would make the caller believe a duplicate is already + // in flight — every image would be silently dropped on a Redis-less setup. + const { svc } = loadService({ available: false }); + await expect(svc.setNX('k', 'v', 300)).resolves.toBe(true); + }); + + it('claims the key when the Redis call throws, for the same reason', async () => { + const { svc } = loadService({ set: () => { throw new Error('ECONNRESET'); } }); + await expect(svc.setNX('k', 'v', 300)).resolves.toBe(true); + }); + + it('never sends a zero or negative TTL', async () => { + const { svc, calls } = loadService(); + await svc.setNX('k', 'v', 0); + await svc.setNX('k', 'v', -5); + expect(calls.set.map((c) => c[3])).toEqual([60, 60]); + }); +}); + +describe('setexWithCeiling', () => { + it('passes a TTL under the ceiling through unchanged', async () => { + const { svc, calls } = loadService(); + await expect(svc.setexWithCeiling('quiz:active:923001234567', 3600, 'state')).resolves.toBe(true); + expect(calls.setex[0]).toEqual(['quiz:active:923001234567', 3600, 'state']); + }); + + it('accepts the 24h TTL the quiz subsystem actually uses', async () => { + const { svc, calls } = loadService(); + await svc.setexWithCeiling('k', 86400, 'v'); + expect(calls.setex[0][1]).toBe(86400); + }); + + it('clamps anything longer to 24h, and says so', async () => { + const { svc, calls, mockLog } = loadService(); + await svc.setexWithCeiling('k', 7 * 86400, 'v'); + expect(calls.setex[0][1]).toBe(86400); + expect(mockLog.mock.calls.flat().join(' ')).toMatch(/Clamped Redis TTL/); + }); + + it('falls back to the ceiling for a missing or nonsense TTL', async () => { + const { svc, calls } = loadService(); + await svc.setexWithCeiling('k', undefined, 'v'); + await svc.setexWithCeiling('k', -1, 'v'); + await svc.setexWithCeiling('k', 'soon', 'v'); + expect(calls.setex.map((c) => c[1])).toEqual([86400, 86400, 86400]); + }); + + it('returns false rather than throwing when Redis is unavailable', async () => { + const { svc } = loadService({ available: false }); + await expect(svc.setexWithCeiling('k', 60, 'v')).resolves.toBe(false); + }); +}); diff --git a/tests/jest.config.js b/tests/jest.config.js index 378c43c..afaa323 100644 --- a/tests/jest.config.js +++ b/tests/jest.config.js @@ -27,6 +27,12 @@ module.exports = { '^pino$': '/tests/__mocks__/pino.js', '^canvas$': '/tests/__mocks__/canvas.js', '^dotenv$': '/tests/__mocks__/dotenv.js', + // ffmpeg + its bundled binaries: bot-only deps that audio.service.js + // configures at import time, so anything reaching the audio/transcription + // services needs them mapped for the root-suite-first CI pass. + '^fluent-ffmpeg$': '/tests/__mocks__/fluent-ffmpeg.js', + '^@ffmpeg-installer/ffmpeg$': '/tests/__mocks__/ffmpeg-installer.js', + '^@ffprobe-installer/ffprobe$': '/tests/__mocks__/ffmpeg-installer.js', }, setupFiles: ['/tests/setup.js'], testEnvironment: 'node', diff --git a/tests/messaging/baileys-channel-service.test.js b/tests/messaging/baileys-channel-service.test.js new file mode 100644 index 0000000..e48656a --- /dev/null +++ b/tests/messaging/baileys-channel-service.test.js @@ -0,0 +1,386 @@ +/** + * baileys-channel.service.js — behavior of the REAL (connection-backed) + * methods. baileys-connection.js is always mocked here so nothing ever opens + * a real socket; tests/messaging/channel-driver-parity.test.js covers the + * still-stubbed Meta-template-only methods and cross-driver existence. + */ + +function loadService({ sendMessageImpl, sendPresenceUpdateImpl } = {}) { + jest.resetModules(); + const sock = { + sendMessage: jest.fn(sendMessageImpl || (async () => ({}))), + sendPresenceUpdate: jest.fn(sendPresenceUpdateImpl || (async () => {})), + }; + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ + downloadFromR2: jest.fn(), + extractKeyFromUrl: jest.fn((url) => url.split('/').pop()), + })); + // The driver records rendered menus here so numeric replies can be resolved + // (see pending-options.js). Mocked so a unit test never opens a real Redis + // connection — that leaks a handle and Jest can't exit. + jest.doMock('../../bot/shared/services/messaging/pending-options', () => ({ + remember: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(null), + clear: jest.fn().mockResolvedValue(undefined), + resolveSelection: jest.fn(() => null), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), + isConnected: jest.fn().mockReturnValue(true), + authDir: jest.fn().mockReturnValue('/tmp/never-used'), + })); + const service = require('../../bot/shared/services/messaging/baileys-channel.service'); + const { downloadFromR2 } = require('../../bot/shared/storage/r2'); + return { service, sock, downloadFromR2 }; +} + +afterEach(() => jest.resetModules()); + +describe('baileys-channel.service — outbound', () => { + it('sendMessage sends {text} to the phone-number JID and strips emotion tags', async () => { + const { service, sock } = loadService(); + const result = await service.sendMessage('923001234567', '[warmly] Hello there'); + expect(result).toBe(true); + expect(sock.sendMessage).toHaveBeenCalledWith('923001234567@s.whatsapp.net', { text: 'Hello there' }); + }); + + it('sendMessage returns false (never throws) when the socket rejects', async () => { + const { service } = loadService({ sendMessageImpl: async () => { throw new Error('boom'); } }); + await expect(service.sendMessage('923001234567', 'hi')).resolves.toBe(false); + }); + + it('sendReaction sends a react content with fromMe:false against the sender\'s JID', async () => { + const { service, sock } = loadService(); + const result = await service.sendReaction('923001234567', 'wamid.ABC', '❤️'); + expect(result).toBe(true); + expect(sock.sendMessage).toHaveBeenCalledWith('923001234567@s.whatsapp.net', { + react: { text: '❤️', key: { remoteJid: '923001234567@s.whatsapp.net', id: 'wamid.ABC', fromMe: false } }, + }); + }); + + it('showTypingIndicator sends a composing presence update to the JID', async () => { + const { service, sock } = loadService(); + const result = await service.showTypingIndicator('923001234567', 'msg-1'); + expect(result).toBe(true); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith('composing', '923001234567@s.whatsapp.net'); + }); + + it('startContinuousTypingIndicator fires an immediate typing update and repeats until stop()', async () => { + jest.useFakeTimers(); + const { service, sock } = loadService(); + const controller = service.startContinuousTypingIndicator('923001234567', 'msg-1'); + expect(typeof controller.stop).toBe('function'); + // advanceTimersByTimeAsync(0) flushes pending microtasks (getSock() then + // sock.sendPresenceUpdate()) in lockstep with fake timers — a plain + // Promise.resolve() tick doesn't reliably drain that chain under fake timers. + await jest.advanceTimersByTimeAsync(0); + + expect(sock.sendPresenceUpdate).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(20000); + expect(sock.sendPresenceUpdate).toHaveBeenCalledTimes(2); + + controller.stop(); + await jest.advanceTimersByTimeAsync(60000); + expect(sock.sendPresenceUpdate).toHaveBeenCalledTimes(2); // no further calls after stop + jest.useRealTimers(); + }); +}); + +describe('baileys-channel.service — inbound media bridge', () => { + it('getMediaInfo/downloadMedia return the cached buffer once cacheIncomingMedia has been called', async () => { + const { service } = loadService(); + const buffer = Buffer.from('fake-audio-bytes'); + service._cacheIncomingMedia('synthetic-id-1', buffer, 'audio/ogg'); + + const info = await service.getMediaInfo('synthetic-id-1'); + expect(info.mime_type).toBe('audio/ogg'); + expect(info.file_size).toBe(buffer.length); + + const downloaded = await service.downloadMedia('synthetic-id-1'); + expect(downloaded).toBe(buffer); + }); + + it('getMediaInfo/downloadMedia reject with a clear message for an id that was never cached', async () => { + const { service } = loadService(); + await expect(service.downloadMedia('unknown-id')).rejects.toThrow(/no cached media for id "unknown-id"/); + }); + + it('a cached media entry can be read multiple times (handlers call downloadMedia repeatedly)', async () => { + const { service } = loadService(); + const buffer = Buffer.from('bytes'); + service._cacheIncomingMedia('id-2', buffer, 'image/jpeg'); + await service.downloadMedia('id-2'); + const second = await service.downloadMedia('id-2'); + expect(second).toBe(buffer); + }); +}); + +describe('baileys-channel.service — URL-based senders', () => { + // Two ways to resolve a media URL, and which is right depends on the URL, not + // on the call site: an authenticated R2 download for a private object, or + // handing Baileys `{url}` to stream a public one. Live testing found /video + // dying with "S3Client cannot be constructed — missing env: R2_ENDPOINT…" on a + // video whose URL was PUBLIC — on a sandbox, which by definition has no R2 + // keys. So the R2 path is exercised with R2 configured, and the direct path + // without. + const R2_ENV = { + R2_ENDPOINT: 'https://acc.r2.cloudflarestorage.com', + R2_ACCESS_KEY_ID: 'test-key', + R2_SECRET_ACCESS_KEY: 'test-secret', + }; + + function withR2(enabled) { + for (const key of Object.keys(R2_ENV)) { + if (enabled) process.env[key] = R2_ENV[key]; + else delete process.env[key]; + } + } + + afterEach(() => withR2(false)); + + describe('with R2 configured (private objects)', () => { + it('sendImageFromUrl downloads via R2 then sends an image message', async () => { + withR2(true); + const { service, sock, downloadFromR2 } = loadService(); + downloadFromR2.mockResolvedValue(Buffer.from('PNGDATA')); + const result = await service.sendImageFromUrl('923001234567', 'https://r2.example/bucket/img.png', 'a caption'); + expect(result).toBe(true); + expect(sock.sendMessage).toHaveBeenCalledWith('923001234567@s.whatsapp.net', { + image: Buffer.from('PNGDATA'), caption: 'a caption', + }); + }); + + it('sendAudioFromUrl downloads via R2 then sends an audio message', async () => { + withR2(true); + const { service, sock, downloadFromR2 } = loadService(); + downloadFromR2.mockResolvedValue(Buffer.from('AUDIODATA')); + const result = await service.sendAudioFromUrl('923001234567', 'https://r2.example/bucket/a.mp3'); + expect(result).toBe(true); + expect(sock.sendMessage).toHaveBeenCalledWith('923001234567@s.whatsapp.net', { + audio: Buffer.from('AUDIODATA'), mimetype: 'audio/mpeg', ptt: false, + }); + }); + + it('falls back to fetching the URL directly when the object is not in R2', async () => { + // A configured R2 does not mean every URL lives in it — a public CDN URL + // from another bucket must still send. + withR2(true); + const { service, sock, downloadFromR2 } = loadService(); + downloadFromR2.mockRejectedValue(new Error('NoSuchKey')); + const result = await service.sendImageFromUrl('923001234567', 'https://cdn.example/img.png'); + expect(result).toBe(true); + expect(sock.sendMessage).toHaveBeenCalledWith('923001234567@s.whatsapp.net', { + image: { url: 'https://cdn.example/img.png' }, caption: '', + }); + }); + }); + + describe('without R2 configured (the sandbox case)', () => { + it('hands Baileys the public URL to stream, never touching the R2 client', async () => { + const { service, sock, downloadFromR2 } = loadService(); + const url = 'https://pub-abc.r2.dev/videos/lesson.mp4'; + const result = await service.sendVideoFromUrl('923001234567', url, 'a caption'); + expect(result).toBe(true); + expect(downloadFromR2).not.toHaveBeenCalled(); + expect(sock.sendMessage).toHaveBeenCalledWith('923001234567@s.whatsapp.net', { + video: { url }, caption: 'a caption', + }); + }); + + it('sendImageFromUrl streams a public URL too', async () => { + const { service, sock } = loadService(); + const result = await service.sendImageFromUrl('923001234567', 'https://cdn.example/i.png'); + expect(result).toBe(true); + expect(sock.sendMessage.mock.calls[0][1].image).toEqual({ url: 'https://cdn.example/i.png' }); + }); + + it('returns false for a non-absolute URL, which nothing here could fetch', async () => { + const { service, sock } = loadService(); + await expect(service.sendImageFromUrl('923001234567', '/feature_videos/intro.mp4')).resolves.toBe(false); + expect(sock.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('file:// URLs — media this deployment generated locally', () => { + // A deployment with no bucket writes generated media to disk and hands back a + // file:// URL (see reading/analysis.service.js's report PDF). Without this the + // report was rendered, then undeliverable. + const os = require('os'); + const nodeFs = require('fs'); + const nodePath = require('path'); + + it('reads the local file and sends its bytes', async () => { + const { service, sock, downloadFromR2 } = loadService(); + const dir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'rumi-doc-')); + const file = nodePath.join(dir, 'report.pdf'); + nodeFs.writeFileSync(file, 'PDFBYTES'); + + const result = await service.sendDocumentFromUrl('923001234567', `file://${file}`, 'report.pdf', 'caption'); + + expect(result).toBe(true); + expect(downloadFromR2).not.toHaveBeenCalled(); + expect(sock.sendMessage.mock.calls[0][1].document).toEqual(Buffer.from('PDFBYTES')); + }); + + it('returns false when the local file has been cleaned up', async () => { + const { service } = loadService(); + await expect(service.sendDocumentFromUrl('923001234567', 'file:///nope/gone.pdf', 'x.pdf')) + .resolves.toBe(false); + }); + }); + + it('returns false (does not throw) when the R2 download fails and the URL is unusable', async () => { + withR2(true); + const { service, downloadFromR2 } = loadService(); + downloadFromR2.mockRejectedValue(new Error('R2 403')); + await expect(service.sendImageFromUrl('923001234567', 'not-a-url')).resolves.toBe(false); + }); +}); + +describe('baileys-channel.service — sendImage/sendSticker media-ID limitation', () => { + it('sendImage refuses a bare media ID (no "/" or "\\\\") — Baileys has no upload-once/reuse-by-id step', async () => { + const { service, sock } = loadService(); + const result = await service.sendImage('923001234567', '1234567890123456'); + expect(result).toBe(false); + expect(sock.sendMessage).not.toHaveBeenCalled(); + }); +}); + +describe('baileys-channel.service — interactive/list methods render as numbered plain text', () => { + it('sendInteractiveButtons renders the body + numbered button titles as text', async () => { + const { service, sock } = loadService(); + await service.sendInteractiveButtons('923001234567', { + body: 'Pick one', buttons: [{ id: 'a', title: 'Option A' }, { id: 'b', title: 'Option B' }], + }); + const [, content] = sock.sendMessage.mock.calls[0]; + expect(content.text).toContain('Pick one'); + expect(content.text).toContain('1. Option A'); + expect(content.text).toContain('2. Option B'); + }); + + it('sendLanguageSelectionList renders all language options as a numbered list', async () => { + const { service, sock } = loadService(); + await service.sendLanguageSelectionList('923001234567'); + const [, content] = sock.sendMessage.mock.calls[0]; + expect(content.text).toContain('1. Auto-detect'); + expect(content.text).toMatch(/اردو/); + }); + + it('sendStyleListFallback and sendFeatureMenuListFallback render their option lists as text', async () => { + const { service, sock } = loadService(); + await service.sendStyleListFallback('923001234567'); + expect(sock.sendMessage.mock.calls[0][1].text).toContain('Photorealistic'); + + sock.sendMessage.mockClear(); + await service.sendFeatureMenuListFallback('923001234567'); + expect(sock.sendMessage.mock.calls[0][1].text).toContain('Lesson Plans'); + }); + + describe('rendered menus are recorded so numeric replies can resolve', () => { + function loadWithStore() { + jest.resetModules(); + const sock = { sendMessage: jest.fn(async () => ({})), sendPresenceUpdate: jest.fn(async () => {}) }; + const pending = { remember: jest.fn().mockResolvedValue(undefined) }; + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ + downloadFromR2: jest.fn().mockResolvedValue(Buffer.from('IMG')), + extractKeyFromUrl: jest.fn((u) => u.split('/').pop()), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/pending-options', () => pending); + return { service: require('../../bot/shared/services/messaging/baileys-channel.service'), pending, sock }; + } + + it('sendInteractiveButtons records the button ids as a button_reply menu', async () => { + const { service, pending } = loadWithStore(); + await service.sendInteractiveButtons('923001234567', { + body: 'Confirm?', + buttons: [{ id: 'coaching_confirm_7', title: 'Yes' }, { id: 'coaching_cancel_7', title: 'No' }], + }); + + expect(pending.remember).toHaveBeenCalledWith('923001234567', { + replyType: 'button_reply', + options: [{ id: 'coaching_confirm_7', title: 'Yes' }, { id: 'coaching_cancel_7', title: 'No' }], + }); + }); + + it('sendLanguageSelectionList records the real lang_* ids in render order', async () => { + // These ids must match meta-channel.service.js exactly — they are what + // whatsapp-bot.js's `listId.startsWith('lang_')` branch dispatches on. + const { service, pending } = loadWithStore(); + await service.sendLanguageSelectionList('923001234567'); + + const [, menu] = pending.remember.mock.calls[0]; + expect(menu.replyType).toBe('list_reply'); + expect(menu.options[0].id).toBe('lang_auto'); + expect(menu.options[1].id).toBe('lang_en'); + expect(menu.options[2].id).toBe('lang_ur'); + // Render order must match the numbered text the user sees. + expect(menu.options.map((o) => o.title)).toEqual( + expect.arrayContaining(['Auto-detect', 'English', 'اردو']) + ); + }); + + it('sendStyleListFallback records the real style_* ids', async () => { + const { service, pending } = loadWithStore(); + await service.sendStyleListFallback('923001234567'); + const [, menu] = pending.remember.mock.calls[0]; + expect(menu.options.map((o) => o.id)).toEqual([ + 'style_photorealistic', 'style_infographic', 'style_cartoon', 'style_sketch', + ]); + }); + + it('sendFeatureMenuListFallback records the real menu_* ids', async () => { + const { service, pending } = loadWithStore(); + await service.sendFeatureMenuListFallback('923001234567'); + const [, menu] = pending.remember.mock.calls[0]; + expect(menu.options.map((o) => o.id)).toEqual([ + 'menu_lesson_plan', 'menu_coaching', 'menu_reading', 'menu_video', 'menu_other', + ]); + }); + + it('sendInteractiveMessage records list rows across all sections, flattened in order', async () => { + const { service, pending } = loadWithStore(); + await service.sendInteractiveMessage('923001234567', { + body: { text: 'Pick' }, + action: { + sections: [ + { rows: [{ id: 'reading_lang_en', title: 'English' }] }, + { rows: [{ id: 'reading_lang_ur', title: 'Urdu' }] }, + ], + }, + }); + const [, menu] = pending.remember.mock.calls[0]; + expect(menu.options.map((o) => o.id)).toEqual(['reading_lang_en', 'reading_lang_ur']); + }); + + it('records nothing when the options carry no ids — there would be nothing to route back to', async () => { + const { service, pending } = loadWithStore(); + await service.sendInteractiveButtons('923001234567', { + body: 'Pick', buttons: [{ title: 'No id here' }], + }); + expect(pending.remember).not.toHaveBeenCalled(); + }); + }); + + describe('toJid', () => { + it('drops a :device suffix instead of folding it into the number', async () => { + // Live bug: Baileys 7.x can yield device-scoped JIDs like `:0`. + // Stripping non-digits first turned that into `0` — the real + // number plus a trailing zero, a nonexistent destination that Baileys + // still reports as successfully "sent". + const { service } = loadService(); + expect(service._toJid('923001234567:0')).toBe('923001234567@s.whatsapp.net'); + expect(service._toJid('923001234567:0@s.whatsapp.net')).toBe('923001234567@s.whatsapp.net'); + }); + + it('still normalises plain and prettified numbers', async () => { + const { service } = loadService(); + expect(service._toJid('923001234567')).toBe('923001234567@s.whatsapp.net'); + expect(service._toJid('+92 300 123 4567')).toBe('923001234567@s.whatsapp.net'); + }); + }); +}); diff --git a/tests/messaging/baileys-connection.test.js b/tests/messaging/baileys-connection.test.js new file mode 100644 index 0000000..03355d2 --- /dev/null +++ b/tests/messaging/baileys-connection.test.js @@ -0,0 +1,727 @@ +/** + * baileys-connection.js — the persistent-socket manager. + * + * `baileys` is pure ESM (real callers load it via ./baileys-lib.js's dynamic + * import() — see that file). Jest's config here can't execute a real dynamic + * import (tests/jest.config.js sets experimentalVmModules: false), so tests + * mock baileys-lib.js's loadBaileys() directly instead of the `baileys` + * package itself — the real import() statement is then never reached. + * `qrcode-terminal` is a normal CJS package and is virtually mocked as usual. + * A real socket/QR/network call must never happen from a unit test regardless. + * + * getSocket() now resolves ONLY once the connection reaches "open" (a real + * bug this fixes — see baileys-connection.js's connect() doc comment), so + * every test that needs a resolved socket must simulate `open` WHILE the + * promise is still pending, not after awaiting it (that would deadlock). + * connectAndOpen() below is the shared helper for that. + */ + +function mockBaileysPackage({ qr } = {}) { + const sockEventHandlers = {}; + const sock = { + ev: { + on: jest.fn((event, handler) => { sockEventHandlers[event] = handler; }), + }, + sendMessage: jest.fn(), + sendPresenceUpdate: jest.fn(), + }; + + const saveCreds = jest.fn(); + const makeWASocket = jest.fn(() => sock); + const useMultiFileAuthState = jest.fn().mockResolvedValue({ state: {}, saveCreds }); + const fetchLatestBaileysVersion = jest.fn().mockResolvedValue({ version: [2, 3000, 0], isLatest: true }); + const DisconnectReason = { loggedOut: 401 }; + + jest.doMock('../../bot/shared/services/messaging/baileys-lib', () => ({ + loadBaileys: jest.fn().mockResolvedValue({ + makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, DisconnectReason, + }), + })); + jest.doMock('qrcode-terminal', () => ({ generate: jest.fn() }), { virtual: true }); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + + return { + sock, sockEventHandlers, saveCreds, makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, + }; +} + +/** Flushes microtask ticks until sockEventHandlers[event] has been registered by connect(). */ +async function waitForHandler(sockEventHandlers, event = 'connection.update') { + for (let i = 0; i < 20 && !sockEventHandlers[event]; i += 1) await Promise.resolve(); + if (!sockEventHandlers[event]) throw new Error(`${event} handler was never registered`); +} + +/** Calls getSocket(), waits for connect() to register its listener, then simulates a clean "open". */ +async function connectAndOpen(conn, sockEventHandlers, opts) { + const socketPromise = conn.getSocket(opts); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ connection: 'open' }); + return socketPromise; +} + +// Every test gets a FRESH, EMPTY channel-state dir. Without this, authDir() +// falls back to the repo's real `.channel-state/`, so tests behaved differently +// depending on whether the developer happened to have a live paired session on +// disk — which is how two of them started failing the moment connect() began +// distinguishing "no credentials yet" from "credentials were invalidated". +beforeEach(() => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + process.env.CHANNEL_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-chan-empty-')); +}); + +afterEach(() => { + jest.resetModules(); + // Repo-anchored, matching authDir() — cwd-relative cleanup would miss the + // folder connect() actually created for its instance lock. + const relativeStateDir = require('path').resolve(__dirname, '../..', '.test-channel-state'); + require('fs').rmSync(relativeStateDir, { recursive: true, force: true }); + delete process.env.CHANNEL_STATE_DIR; +}); + +describe('baileys-connection', () => { + it('connects lazily: requiring the module does not call makeWASocket', () => { + jest.resetModules(); + const { makeWASocket } = mockBaileysPackage(); + require('../../bot/shared/services/messaging/baileys-connection'); + expect(makeWASocket).not.toHaveBeenCalled(); + }); + + it('getSocket() resolves only once "open" fires — not as soon as makeWASocket() returns', async () => { + jest.resetModules(); + const { makeWASocket, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + let resolved = false; + const socketPromise = conn.getSocket().then((sock) => { resolved = true; return sock; }); + await waitForHandler(sockEventHandlers); + expect(makeWASocket).toHaveBeenCalledTimes(1); + expect(resolved).toBe(false); // socket shell exists, but not yet "open" + + sockEventHandlers['connection.update']({ connection: 'open' }); + await socketPromise; + expect(resolved).toBe(true); + }); + + it('getSocket() calls useMultiFileAuthState with CHANNEL_STATE_DIR/baileys, and makeWASocket with the resulting auth + version', async () => { + jest.resetModules(); + // A RELATIVE value on purpose: CHANNEL_STATE_DIR defaults to the relative + // '.channel-state', and the behaviour under test is that it resolves against + // the REPO, not the working directory. + // + // Resolved against cwd, the session moved whenever the launch directory did: + // `cd bot && npm start` used an empty bot/.channel-state, so Baileys + // registered a *second* device and re-synced from scratch, endlessly, with + // two devices fighting over one account. Seen live on a real account — + // device :13 at the repo root and :14 under bot/. + process.env.CHANNEL_STATE_DIR = '.test-channel-state'; + const { makeWASocket, useMultiFileAuthState, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + + expect(useMultiFileAuthState).toHaveBeenCalledTimes(1); + const authPath = useMultiFileAuthState.mock.calls[0][0]; + expect(authPath).toMatch(/\.test-channel-state[/\\]baileys$/); + // Anchored to the repo root, whatever the working directory is. + expect(authPath).toBe(require('path').resolve(__dirname, '../..', '.test-channel-state', 'baileys')); + expect(makeWASocket).toHaveBeenCalledTimes(1); + }); + + it('authDir() ignores the working directory entirely', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + jest.resetModules(); + process.env.CHANNEL_STATE_DIR = '.test-channel-state'; + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + const fromRepoRoot = conn.authDir(); + + const original = process.cwd(); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-authdir-cwd-')); + process.chdir(elsewhere); + try { + expect(conn.authDir()).toBe(fromRepoRoot); + } finally { + process.chdir(original); + fs.rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + it('does not hand Baileys an undefined logger — it would overwrite their default', async () => { + // The bug: `logger: isCli ? quiet : undefined`. Baileys merges config over + // its defaults, so an explicit undefined replaced its default logger and the + // next `logger.child()` call threw "Cannot read properties of undefined". + // The bot booted, reported every other service healthy, and had no WhatsApp + // connection at all. Checked as a property of the config actually passed, + // since the source read as though it were a no-op. + jest.resetModules(); + delete process.env.RUMI_CLI; + const { makeWASocket, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + + const config = makeWASocket.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(config, 'logger')).toBe(false); + }); + + it('gives Baileys a silent logger when an interactive command is driving', async () => { + jest.resetModules(); + process.env.RUMI_CLI = '1'; + const { makeWASocket, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + try { + await connectAndOpen(conn, sockEventHandlers); + const { logger } = makeWASocket.mock.calls[0][0]; + expect(logger).toBeDefined(); + expect(typeof logger.child).toBe('function'); + expect(typeof logger.child({ class: 'baileys' }).info).toBe('function'); + } finally { + delete process.env.RUMI_CLI; + } + }); + + it('honours an ABSOLUTE CHANNEL_STATE_DIR as given', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + jest.resetModules(); + const absolute = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-authdir-abs-')); + process.env.CHANNEL_STATE_DIR = absolute; + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + expect(conn.authDir()).toBe(path.join(absolute, 'baileys')); + fs.rmSync(absolute, { recursive: true, force: true }); + }); + + it('getSocket() is memoized — a second call reuses the same connection without reconnecting', async () => { + jest.resetModules(); + const { makeWASocket, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const first = await connectAndOpen(conn, sockEventHandlers); + const second = await conn.getSocket(); // already resolved — no new registration needed + + expect(first).toBe(second); + expect(makeWASocket).toHaveBeenCalledTimes(1); + }); + + it('registers a creds.update listener that calls saveCreds', async () => { + jest.resetModules(); + const { sockEventHandlers, saveCreds } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + expect(typeof sockEventHandlers['creds.update']).toBe('function'); + expect(sockEventHandlers['creds.update']).toBe(saveCreds); + }); + + it('renders a QR code via qrcode-terminal and invokes onQr when one is issued', async () => { + jest.resetModules(); + const { sockEventHandlers } = mockBaileysPackage(); + const qrcodeTerminal = require('qrcode-terminal'); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const onQr = jest.fn(); + const socketPromise = conn.getSocket({ onQr }); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ qr: 'raw-qr-payload' }); + + expect(qrcodeTerminal.generate).toHaveBeenCalledWith('raw-qr-payload', expect.any(Object)); + expect(onQr).toHaveBeenCalledWith('raw-qr-payload'); + + sockEventHandlers['connection.update']({ connection: 'open' }); // let the promise settle cleanly + await socketPromise; + }); + + it('marks isConnected() true on connection open and false on close', async () => { + jest.resetModules(); + const { sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const socketPromise = conn.getSocket(); + await waitForHandler(sockEventHandlers); + expect(conn.isConnected()).toBe(false); + + sockEventHandlers['connection.update']({ connection: 'open' }); + await socketPromise; + expect(conn.isConnected()).toBe(true); + + sockEventHandlers['connection.update']({ connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } }); + expect(conn.isConnected()).toBe(false); + }); + + it('reconnects automatically on a non-logout close AFTER an established connection (statusCode !== 401)', async () => { + jest.resetModules(); + const { sockEventHandlers, makeWASocket } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + expect(makeWASocket).toHaveBeenCalledTimes(1); + + sockEventHandlers['connection.update']({ connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } }); + // The reconnect fires an async getSocket() internally, which now chains + // through loadBaileys() too — flush enough microtask ticks for the whole thing. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(makeWASocket).toHaveBeenCalledTimes(2); + }); + + it('a close BEFORE ever opening chains this attempt\'s promise onto the internal reconnect, instead of hanging forever', async () => { + // Real-world shape: the very first attempt hits a transient close (not a + // logout) before "open" ever fires. A caller awaiting THIS getSocket() + // call must still eventually resolve once the internal reconnect (a + // fresh socket) actually opens — not hang on the abandoned first attempt. + jest.resetModules(); + const { sockEventHandlers, makeWASocket } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const socketPromise = conn.getSocket(); + await waitForHandler(sockEventHandlers); + + sockEventHandlers['connection.update']({ connection: 'close', lastDisconnect: { error: { output: { statusCode: 515 } } } }); + // Internal reconnect: flush ticks so the new connect() attempt registers its own handler. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(makeWASocket).toHaveBeenCalledTimes(2); + + sockEventHandlers['connection.update']({ connection: 'open' }); + const sock = await socketPromise; // resolves via the chained reconnect, not the abandoned first attempt + expect(sock).toBeTruthy(); + }); + + it('events emitter survives an internal reconnect — the exact bug a live pairing run caught', async () => { + // Real-world discovery: baileys-pair.js used to attach its success + // listener to the FIRST socket's own `sock.ev`. After a live pairing, + // Baileys closes with "restart required" (515) and this module + // reconnects internally with a brand-new socket — a listener on the old + // socket never sees the real, second "open" and times out despite the + // pairing having actually succeeded. `events` must not have that problem: + // a listener registered once must fire for every subsequent "open", + // including ones after an internal reconnect. + jest.resetModules(); + const { sockEventHandlers, makeWASocket } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const opens = jest.fn(); + conn.events.on('open', opens); + + await connectAndOpen(conn, sockEventHandlers); + expect(opens).toHaveBeenCalledTimes(1); + + // "restart required" close — not a logout, triggers an internal reconnect. + sockEventHandlers['connection.update']({ + connection: 'close', lastDisconnect: { error: { output: { statusCode: 515 } } }, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(makeWASocket).toHaveBeenCalledTimes(2); // the internal reconnect really happened + + // The reconnected socket opens for real — the SAME `events` listener + // (registered before any of this happened) must fire again. + sockEventHandlers['connection.update']({ connection: 'open' }); + expect(opens).toHaveBeenCalledTimes(2); + }); + + it('passes a getMessage hook to makeWASocket so Baileys can answer retry receipts', async () => { + // The real bug: the paired phone showed "Waiting for this message. This + // may take a while." forever. WhatsApp's recovery path is a retry receipt, + // which Baileys answers in sendMessagesAgain() by calling the `getMessage` + // config hook to recover the original content. The library default is + // `async () => undefined`, so nothing was ever resent. + jest.resetModules(); + const { makeWASocket, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + + const opts = makeWASocket.mock.calls[0][0]; + expect(typeof opts.getMessage).toBe('function'); + }); + + it('pins the socket options that differ from Baileys 7.x defaults', async () => { + // Baileys 7.x defaults syncFullHistory AND markOnlineOnConnect to true. + // Both are wrong for this bot: it only acts on messages received while + // running, and marking the account online suppresses push notifications on + // the operator's own phone for as long as the bot is up. + jest.resetModules(); + const { makeWASocket, sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + + const opts = makeWASocket.mock.calls[0][0]; + expect(opts.syncFullHistory).toBe(false); + expect(opts.markOnlineOnConnect).toBe(false); + expect(opts.browser).toEqual(['Rumi', 'Chrome', '120.0']); + }); + + it('records sent messages and serves them back to getMessage() by id', async () => { + jest.resetModules(); + const { sock, sockEventHandlers } = mockBaileysPackage(); + const content = { conversation: 'the original reply' }; + sock.sendMessage = jest.fn().mockResolvedValue({ key: { id: 'sent-1' }, message: content }); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + conn._resetForTests(); + + const connected = await connectAndOpen(conn, sockEventHandlers); + await connected.sendMessage('923001234567@s.whatsapp.net', { text: 'the original reply' }); + + // This is what Baileys calls on a retry receipt — it must find the content. + await expect(conn.getStoredMessage({ id: 'sent-1' })).resolves.toBe(content); + }); + + it('getMessage() resolves undefined for an unknown id rather than throwing', async () => { + jest.resetModules(); + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + conn._resetForTests(); + + await expect(conn.getStoredMessage({ id: 'never-sent' })).resolves.toBeUndefined(); + await expect(conn.getStoredMessage(undefined)).resolves.toBeUndefined(); + }); + + it('bounds the sent-message store, evicting oldest first', async () => { + jest.resetModules(); + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + conn._resetForTests(); + + for (let i = 0; i < 260; i += 1) { + conn.rememberSentMessage({ key: { id: `m${i}` }, message: { conversation: `#${i}` } }); + } + + await expect(conn.getStoredMessage({ id: 'm0' })).resolves.toBeUndefined(); // evicted + await expect(conn.getStoredMessage({ id: 'm259' })).resolves.toEqual({ conversation: '#259' }); + }); + + it('serialises overlapping sends so no two encrypt concurrently — the "Waiting for this message" bug', async () => { + // Concurrent sends on one Baileys socket corrupt Signal ratchet state: two + // encryptions advance from the same chain key and the loser is + // undecryptable, showing "Waiting for this message. This may take a while." + // This bot overlaps sends by default (reaction + typing presence + reply, + // with the continuous typing indicator firing on a timer during the send). + jest.resetModules(); + const { sock, sockEventHandlers } = mockBaileysPackage(); + + let inFlight = 0; + let maxConcurrent = 0; + const release = []; + // Keep our own handle: trackSentMessages REPLACES sock.sendMessage with its + // serialising wrapper, so sock.sendMessage is no longer this mock. + const rawSend = jest.fn(() => { + inFlight += 1; + maxConcurrent = Math.max(maxConcurrent, inFlight); + return new Promise((resolve) => { + release.push(() => { inFlight -= 1; resolve({ key: { id: `id-${release.length}` }, message: {} }); }); + }); + }); + sock.sendMessage = rawSend; + + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + conn._resetForTests(); + const connected = await connectAndOpen(conn, sockEventHandlers); + + // Fire three sends without awaiting — the overlapping real-world pattern. + const sends = [ + connected.sendMessage('a@s.whatsapp.net', { text: '1' }), + connected.sendMessage('a@s.whatsapp.net', { text: '2' }), + connected.sendMessage('a@s.whatsapp.net', { text: '3' }), + ]; + + // Drain: only one should ever be in flight, so release them one at a time. + for (let i = 0; i < 3; i += 1) { + for (let tick = 0; tick < 10; tick += 1) await Promise.resolve(); + expect(release.length).toBe(i + 1); // the next send hasn't started yet + release[i](); + } + await Promise.all(sends); + + expect(rawSend).toHaveBeenCalledTimes(3); + expect(maxConcurrent).toBe(1); + }); + + it('a failed send does not wedge the serialised queue for later sends', async () => { + jest.resetModules(); + const { sock, sockEventHandlers } = mockBaileysPackage(); + sock.sendMessage = jest.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ key: { id: 'after-failure' }, message: { conversation: 'ok' } }); + + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + conn._resetForTests(); + const connected = await connectAndOpen(conn, sockEventHandlers); + + await expect(connected.sendMessage('a@s.whatsapp.net', { text: 'fails' })).rejects.toThrow('boom'); + await expect(connected.sendMessage('a@s.whatsapp.net', { text: 'works' })).resolves.toMatchObject({ + key: { id: 'after-failure' }, + }); + }); + + it('close() ends the socket cleanly WITHOUT logging out, and does not reconnect', async () => { + // Real-world discovery: the process was SIGTERM'd with no shutdown + // handling, so Baileys' not-yet-flushed Signal session state was lost and + // the paired phone could no longer decrypt the bot's replies ("Waiting + // for this message. This may take a while."). close() exists to make a + // restart safe. sock.end(undefined) must be a clean close, NOT a logout — + // a logout would destroy the pairing and force a re-scan. + jest.resetModules(); + const { sock, sockEventHandlers, makeWASocket } = mockBaileysPackage(); + sock.end = jest.fn(); + sock.logout = jest.fn(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + expect(conn.isConnected()).toBe(true); + + await conn.close({ flushMs: 0 }); + + expect(sock.end).toHaveBeenCalledWith(undefined); + expect(sock.logout).not.toHaveBeenCalled(); + expect(conn.isConnected()).toBe(false); + + // The real sock.end() emits a 'close' connection.update. That must NOT be + // treated as a network blip and trigger the usual auto-reconnect. + sockEventHandlers['connection.update']({ + connection: 'close', lastDisconnect: { error: { output: { statusCode: 428 } } }, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(makeWASocket).toHaveBeenCalledTimes(1); // no resurrection + }); + + it('close() waits out its flush window so pending auth-state writes can land', async () => { + jest.resetModules(); + const { sock, sockEventHandlers } = mockBaileysPackage(); + sock.end = jest.fn(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + + let settled = false; + const closing = conn.close({ flushMs: 40 }).then(() => { settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); // still inside the flush window + + await closing; + expect(settled).toBe(true); + }); + + it('close() is safe when no connection was ever opened', async () => { + jest.resetModules(); + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await expect(conn.close({ flushMs: 0 })).resolves.toBeUndefined(); + }); + + it('does NOT reconnect on a logout close (statusCode === 401), and rejects this attempt cleanly', async () => { + jest.resetModules(); + const { sockEventHandlers, makeWASocket } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const socketPromise = conn.getSocket(); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ connection: 'close', lastDisconnect: { error: { output: { statusCode: 401 } } } }); + + await expect(socketPromise).rejects.toThrow(/logged out/i); + expect(makeWASocket).toHaveBeenCalledTimes(1); + }); +}); + +describe('a QR when credentials already exist is terminal, not a retry loop', () => { + // The failure this prevents, observed live: WhatsApp invalidated the session + // ("Stream Errored (conflict)" — two processes had shared one auth folder). + // Baileys then re-issued a pairing QR every ~20 seconds indefinitely into a + // terminal nobody was watching. That is not recovery, it is hammering + // WhatsApp's pairing endpoint, and it is exactly how this project repeatedly + // tripped the "can't link new devices right now" rate limit. A human with the + // phone is required, so the only correct behaviour is to stop and say so. + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + function stateDirWithCreds(withCreds) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-chan-')); + if (withCreds) { + fs.mkdirSync(path.join(dir, 'baileys'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'baileys', 'creds.json'), '{"me":{"id":"1@s.whatsapp.net"}}'); + } + process.env.CHANNEL_STATE_DIR = dir; + return dir; + } + + it('rejects, emits a logged-out close, and renders NO QR', async () => { + stateDirWithCreds(true); + const { sockEventHandlers } = mockBaileysPackage(); + const qrTerminal = require('qrcode-terminal'); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const closes = []; + conn.events.on('close', (payload) => closes.push(payload)); + + const pending = conn.getSocket(); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ qr: 'QR-PAYLOAD-1' }); + + await expect(pending).rejects.toThrow(/session invalidated/i); + expect(qrTerminal.generate).not.toHaveBeenCalled(); + // Reported as logged-out so whatsapp-bot.js's exitOnChannelLogout() exits 78 + // and the supervisor stops instead of restarting into the same loop. + expect(closes).toEqual([expect.objectContaining({ loggedOut: true })]); + }); + + it('never asks WhatsApp for a second QR', async () => { + stateDirWithCreds(true); + const { sockEventHandlers } = mockBaileysPackage(); + const qrTerminal = require('qrcode-terminal'); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const pending = conn.getSocket(); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ qr: 'QR-1' }); + sockEventHandlers['connection.update']({ qr: 'QR-2' }); + sockEventHandlers['connection.update']({ qr: 'QR-3' }); + + await expect(pending).rejects.toThrow(); + expect(qrTerminal.generate).not.toHaveBeenCalled(); + }); + + it('STILL shows a QR for genuine first-time pairing (no credentials yet)', async () => { + stateDirWithCreds(false); + const { sockEventHandlers } = mockBaileysPackage(); + const qrTerminal = require('qrcode-terminal'); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const onQr = jest.fn(); + const pending = connectAndOpen(conn, sockEventHandlers, { onQr }); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ qr: 'QR-FIRST-TIME' }); + + await pending; + expect(qrTerminal.generate).toHaveBeenCalledWith('QR-FIRST-TIME', { small: true }); + expect(onQr).toHaveBeenCalledWith('QR-FIRST-TIME'); + }); + + it('allowRepair lets the pairing script re-pair over stale credentials', async () => { + // `npm run pair:baileys` exists precisely to recover this state, so it opts + // in — while the bot, which passes no flag, still refuses to loop. + stateDirWithCreds(true); + const { sockEventHandlers } = mockBaileysPackage(); + const qrTerminal = require('qrcode-terminal'); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + const pending = connectAndOpen(conn, sockEventHandlers, { allowRepair: true }); + await waitForHandler(sockEventHandlers); + sockEventHandlers['connection.update']({ qr: 'QR-REPAIR' }); + + await pending; + expect(qrTerminal.generate).toHaveBeenCalledWith('QR-REPAIR', { small: true }); + }); +}); + +describe('single-instance guard on the auth folder', () => { + // Why this exists, observed live: two processes briefly shared one auth folder + // during a fast restart, WhatsApp rejected the duplicate with "Stream Errored + // (conflict)", and the SESSION ITSELF was invalidated — recovery needed a human + // re-scanning a QR. Overlapping restarts are routine (supervisor restart-on-exit, + // a PaaS rolling deploy draining the old container), so refusing to boot is + // vastly better than destroying the pairing. + const fs = require('fs'); + const path = require('path'); + + it('claims the folder with a lock naming this process', async () => { + const { sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + + const holder = JSON.parse(fs.readFileSync(conn.lockPath(), 'utf-8')); + expect(holder.pid).toBe(process.pid); + expect(typeof holder.since).toBe('string'); + }); + + it('REFUSES to connect when a live process already holds the folder', async () => { + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + // A live pid that is not us: the test runner's own parent is guaranteed alive. + fs.mkdirSync(conn.authDir(), { recursive: true }); + fs.writeFileSync(conn.lockPath(), JSON.stringify({ pid: process.ppid, since: 'earlier' })); + + await expect(conn.getSocket()).rejects.toThrow(/Another Rumi instance \(pid \d+/); + // and it names the remedy rather than just failing + await expect(conn.getSocket()).rejects.toThrow(/Stop the other instance first/); + }); + + it('takes over a STALE lock whose holder is gone', async () => { + const { sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + // pid 0 is never a live process we can signal — stands in for a crashed one. + fs.mkdirSync(conn.authDir(), { recursive: true }); + fs.writeFileSync(conn.lockPath(), JSON.stringify({ pid: 0, since: 'long ago' })); + + await connectAndOpen(conn, sockEventHandlers); + + expect(JSON.parse(fs.readFileSync(conn.lockPath(), 'utf-8')).pid).toBe(process.pid); + }); + + it('treats a corrupt lock file as stale rather than deadlocking forever', async () => { + const { sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + fs.mkdirSync(conn.authDir(), { recursive: true }); + fs.writeFileSync(conn.lockPath(), 'not json at all'); + + await connectAndOpen(conn, sockEventHandlers); + expect(JSON.parse(fs.readFileSync(conn.lockPath(), 'utf-8')).pid).toBe(process.pid); + }); + + it('releases the lock on close(), so a restart can claim it', async () => { + const { sockEventHandlers } = mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + await connectAndOpen(conn, sockEventHandlers); + expect(fs.existsSync(conn.lockPath())).toBe(true); + + await conn.close({ flushMs: 0 }); + expect(fs.existsSync(conn.lockPath())).toBe(false); + }); + + it('does not delete a lock that belongs to somebody else', async () => { + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + fs.mkdirSync(conn.authDir(), { recursive: true }); + fs.writeFileSync(conn.lockPath(), JSON.stringify({ pid: process.ppid, since: 'earlier' })); + + conn.releaseInstanceLock(); + expect(fs.existsSync(conn.lockPath())).toBe(true); + }); + + it('is idempotent — claiming twice from one process is fine', async () => { + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + + expect(() => conn.acquireInstanceLock()).not.toThrow(); + expect(() => conn.acquireInstanceLock()).not.toThrow(); + expect(JSON.parse(fs.readFileSync(conn.lockPath(), 'utf-8')).pid).toBe(process.pid); + }); + + it('puts the lock inside the driver subfolder, not the shared state root', () => { + mockBaileysPackage(); + const conn = require('../../bot/shared/services/messaging/baileys-connection'); + expect(conn.lockPath()).toBe(path.join(conn.authDir(), '.instance.lock')); + expect(conn.authDir().endsWith(path.join('baileys'))).toBe(true); + }); +}); diff --git a/tests/messaging/baileys-socket-adapter.test.js b/tests/messaging/baileys-socket-adapter.test.js new file mode 100644 index 0000000..f181d5e --- /dev/null +++ b/tests/messaging/baileys-socket-adapter.test.js @@ -0,0 +1,710 @@ +/** + * baileys-socket.adapter.js — translates a Baileys WAMessage into the same + * shape validators.js#validateWebhookMessage produces from a real Meta + * webhook body, so handleWebhookPost's existing dispatch logic (untouched — + * see the mechanical extraction in bot/whatsapp-bot.js) can process both. + */ + +const path = require('path'); + +// The adapter now consults pending-options.js to turn a numeric reply into an +// interactive selection. pending-options lazy-requires the Redis service, so +// stub that ONE module file-wide: pending-options' real logic still runs (via +// its in-memory fallback), but no socket is opened — an open Redis handle stops +// Jest from exiting. +jest.mock('../../bot/shared/services/cache/railway-redis.service', () => ({ + set: jest.fn().mockRejectedValue(new Error('redis disabled in tests')), + get: jest.fn().mockRejectedValue(new Error('redis disabled in tests')), + delete: jest.fn().mockRejectedValue(new Error('redis disabled in tests')), +})); + +function loadAdapter() { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn(), isConnected: jest.fn(), authDir: jest.fn(), + })); + return require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); +} + +afterEach(() => jest.resetModules()); + +describe('jidToPhoneNumber', () => { + it('strips the @s.whatsapp.net suffix', () => { + const { jidToPhoneNumber } = loadAdapter(); + expect(jidToPhoneNumber('923001234567@s.whatsapp.net')).toBe('923001234567'); + }); + + it('strips a :device suffix — Baileys 7.x getPNForLID() returns device-scoped JIDs', () => { + // Live bug: getPNForLID() returned `:0@s.whatsapp.net`. Keeping the + // ":0" meant it was stored as the user's phone_number, and toJid()'s + // strip-non-digits turned `:0` into `0` — the real number + // with a trailing zero, a nonexistent destination Baileys still calls "sent". + const { jidToPhoneNumber } = loadAdapter(); + expect(jidToPhoneNumber('923001234567:0@s.whatsapp.net')).toBe('923001234567'); + expect(jidToPhoneNumber('923001234567:22@s.whatsapp.net')).toBe('923001234567'); + expect(jidToPhoneNumber('111222333444555:1@lid')).toBe('111222333444555'); + }); +}); + +describe('resolveSenderPhoneNumber', () => { + it('uses remoteJid directly for a normal @s.whatsapp.net chat', () => { + const { resolveSenderPhoneNumber } = loadAdapter(); + const waMessage = { key: { remoteJid: '923001234567@s.whatsapp.net' } }; + expect(resolveSenderPhoneNumber(waMessage)).toBe('923001234567'); + }); + + it('prefers key.senderPn over the opaque @lid id — the real-world bug a live inbound test caught', () => { + // WhatsApp's phone-number-privacy rollout can address a chat via an + // opaque @lid JID. Before this fix, `from` became the LID's numeric id + // (not a real phone number) — DB user identity was wrong, and the reply + // was sent to a nonexistent JID: Baileys reported success but nothing + // was ever delivered to the real sender. + const { resolveSenderPhoneNumber } = loadAdapter(); + const waMessage = { + key: { remoteJid: '111222333444555@lid', senderPn: '923001234567@s.whatsapp.net' }, + }; + expect(resolveSenderPhoneNumber(waMessage)).toBe('923001234567'); + }); + + it('falls back to the LID id when senderPn is absent and nothing is cached yet (best effort)', () => { + const { resolveSenderPhoneNumber } = loadAdapter(); + const waMessage = { key: { remoteJid: '111222333444555@lid' } }; + expect(resolveSenderPhoneNumber(waMessage)).toBe('111222333444555'); + }); + + it('reuses a cached mapping for a later message from the same @lid missing senderPn — the offline-catch-up bug a live test caught', () => { + // Real-world discovery: a message delivered through Baileys' offline/ + // backlog catch-up path (exactly what happens right after this process + // restarts) reaches messages.upsert WITHOUT key.senderPn, even though a + // prior real-time message from the identical @lid resolved one. Without + // this cache, EVERY offline-delivered message regresses to the + // undeliverable-reply bug, not just the very first contact ever seen. + const { resolveSenderPhoneNumber, _resetLidCacheForTests } = loadAdapter(); + _resetLidCacheForTests(); + + const withSenderPn = { key: { remoteJid: '111222333444555@lid', senderPn: '923001234567@s.whatsapp.net' } }; + expect(resolveSenderPhoneNumber(withSenderPn)).toBe('923001234567'); + + const withoutSenderPn = { key: { remoteJid: '111222333444555@lid' } }; + expect(resolveSenderPhoneNumber(withoutSenderPn)).toBe('923001234567'); + }); + + it('does not leak a cached mapping across different @lid ids', () => { + const { resolveSenderPhoneNumber, _resetLidCacheForTests } = loadAdapter(); + _resetLidCacheForTests(); + + resolveSenderPhoneNumber({ key: { remoteJid: '111@lid', senderPn: '923000000001@s.whatsapp.net' } }); + const other = resolveSenderPhoneNumber({ key: { remoteJid: '222@lid' } }); + expect(other).toBe('222'); + }); +}); + +describe('isCommandLike — a command must never be eaten by a pending question', () => { + // Live failure this exists for: a class-setup flow was waiting for the roster + // (a FREE-TEXT step, which accepts any non-empty text), the teacher typed + // "add class", and it became a class whose only student was named "add class". + it('recognises slash commands', () => { + const { isCommandLike } = loadAdapter(); + expect(isCommandLike('/menu')).toBe(true); + expect(isCommandLike(' /video ')).toBe(true); + expect(isCommandLike('/reading test')).toBe(true); + }); + + it('recognises the plain-phrase commands that have no slash', () => { + const { isCommandLike } = loadAdapter(); + expect(isCommandLike('add class')).toBe(true); + expect(isCommandLike('set up class')).toBe(true); + expect(isCommandLike('attendance')).toBe(true); + }); + + it('does not mistake ordinary answers or prose for commands', () => { + const { isCommandLike } = loadAdapter(); + expect(isCommandLike('Ahmed Khan')).toBe(false); + expect(isCommandLike('2')).toBe(false); + expect(isCommandLike('Grade 4')).toBe(false); + expect(isCommandLike('Fluency + Comprehension')).toBe(false); + expect(isCommandLike('')).toBe(false); + expect(isCommandLike(null)).toBe(false); + }); +}); + +describe('isDuplicateDelivery', () => { + it('returns false the first time a message id is seen, true on a repeat', () => { + const { isDuplicateDelivery, _resetSeenMessagesForTests } = loadAdapter(); + _resetSeenMessagesForTests(); + + expect(isDuplicateDelivery('m1')).toBe(false); + expect(isDuplicateDelivery('m1')).toBe(true); + }); + + it('treats different message ids independently', () => { + const { isDuplicateDelivery, _resetSeenMessagesForTests } = loadAdapter(); + _resetSeenMessagesForTests(); + + expect(isDuplicateDelivery('m1')).toBe(false); + expect(isDuplicateDelivery('m2')).toBe(false); + expect(isDuplicateDelivery('m1')).toBe(true); + expect(isDuplicateDelivery('m2')).toBe(true); + }); + + it('never flags a missing/falsy id as a duplicate', () => { + const { isDuplicateDelivery, _resetSeenMessagesForTests } = loadAdapter(); + _resetSeenMessagesForTests(); + + expect(isDuplicateDelivery(undefined)).toBe(false); + expect(isDuplicateDelivery(undefined)).toBe(false); + }); +}); + +describe('mapToMetaShape', () => { + const baseKey = { remoteJid: '923001234567@s.whatsapp.net', id: 'wa-msg-1', fromMe: false }; + + it('maps a plain-text conversation message', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { key: baseKey, messageTimestamp: 1700000000, message: { conversation: 'Hello there' } }; + + const result = await mapToMetaShape(waMessage, jest.fn()); + + expect(result.mediaToCache).toBeNull(); + expect(result.metaMessage).toEqual({ + from: '923001234567', id: 'wa-msg-1', timestamp: 1700000000, type: 'text', text: { body: 'Hello there' }, + }); + }); + + it('maps an extendedTextMessage (text with a link preview / quoted reply) the same as conversation', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { + key: baseKey, messageTimestamp: 1700000001, message: { extendedTextMessage: { text: 'Check this out' } }, + }; + + const result = await mapToMetaShape(waMessage, jest.fn()); + expect(result.metaMessage.type).toBe('text'); + expect(result.metaMessage.text.body).toBe('Check this out'); + }); + + it('maps an image message and returns the downloaded buffer to cache', async () => { + const { mapToMetaShape } = loadAdapter(); + const buffer = Buffer.from('PNGDATA'); + const downloadMediaMessage = jest.fn().mockResolvedValue(buffer); + const waMessage = { + key: baseKey, + messageTimestamp: 1700000002, + message: { imageMessage: { mimetype: 'image/jpeg', caption: 'a caption' } }, + }; + + const result = await mapToMetaShape(waMessage, downloadMediaMessage); + + expect(downloadMediaMessage).toHaveBeenCalledWith(waMessage, 'buffer', {}); + expect(result.metaMessage).toMatchObject({ + type: 'image', image: { id: 'wa-msg-1', mime_type: 'image/jpeg', caption: 'a caption' }, + }); + expect(result.mediaToCache).toEqual({ id: 'wa-msg-1', buffer, mimetype: 'image/jpeg' }); + }); + + it('maps a voice note (ptt:true audio) with type "voice"', async () => { + const { mapToMetaShape } = loadAdapter(); + const buffer = Buffer.from('OGGDATA'); + const downloadMediaMessage = jest.fn().mockResolvedValue(buffer); + const waMessage = { + key: baseKey, messageTimestamp: 1700000003, message: { audioMessage: { mimetype: 'audio/ogg', ptt: true } }, + }; + + const result = await mapToMetaShape(waMessage, downloadMediaMessage); + expect(result.metaMessage.type).toBe('voice'); + expect(result.metaMessage.audio).toEqual({ id: 'wa-msg-1', mime_type: 'audio/ogg' }); + }); + + it('maps a regular (non-ptt) audio message with type "audio"', async () => { + const { mapToMetaShape } = loadAdapter(); + const downloadMediaMessage = jest.fn().mockResolvedValue(Buffer.from('MP3DATA')); + const waMessage = { + key: baseKey, messageTimestamp: 1700000004, message: { audioMessage: { mimetype: 'audio/mpeg', ptt: false } }, + }; + + const result = await mapToMetaShape(waMessage, downloadMediaMessage); + expect(result.metaMessage.type).toBe('audio'); + }); + + it('maps a document message with its filename and mimetype', async () => { + const { mapToMetaShape } = loadAdapter(); + const buffer = Buffer.from('PDFDATA'); + const downloadMediaMessage = jest.fn().mockResolvedValue(buffer); + const waMessage = { + key: baseKey, + messageTimestamp: 1700000005, + message: { documentMessage: { mimetype: 'application/pdf', fileName: 'lesson.pdf' } }, + }; + + const result = await mapToMetaShape(waMessage, downloadMediaMessage); + expect(result.metaMessage).toMatchObject({ + type: 'document', document: { id: 'wa-msg-1', mime_type: 'application/pdf', filename: 'lesson.pdf' }, + }); + expect(result.mediaToCache.buffer).toBe(buffer); + }); + + it('returns null for a message with no content (e.g. protocol/revoke message)', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { key: baseKey, messageTimestamp: 1700000006, message: null }; + expect(await mapToMetaShape(waMessage, jest.fn())).toBeNull(); + }); + + it('returns null for our own outgoing message (fromMe:true) — avoids re-processing echoes', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { + key: { ...baseKey, fromMe: true }, messageTimestamp: 1700000007, message: { conversation: 'hi' }, + }; + expect(await mapToMetaShape(waMessage, jest.fn())).toBeNull(); + }); + + it('returns null for a group chat JID (@g.us) — Rumi is 1:1 only', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { + key: { remoteJid: '123456-group@g.us', id: 'x', fromMe: false }, + messageTimestamp: 1700000008, + message: { conversation: 'hi group' }, + }; + expect(await mapToMetaShape(waMessage, jest.fn())).toBeNull(); + }); + + it('returns null for an unsupported content type (e.g. sticker) rather than throwing', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { key: baseKey, messageTimestamp: 1700000009, message: { stickerMessage: {} } }; + expect(await mapToMetaShape(waMessage, jest.fn())).toBeNull(); + }); + + it('maps a @lid-addressed message using senderPn as `from`, not the LID id', async () => { + const { mapToMetaShape } = loadAdapter(); + const waMessage = { + key: { remoteJid: '111222333444555@lid', senderPn: '923001234567@s.whatsapp.net', id: 'wa-msg-lid', fromMe: false }, + messageTimestamp: 1700000010, + message: { conversation: 'hi from a LID-addressed chat' }, + }; + + const result = await mapToMetaShape(waMessage, jest.fn()); + expect(result.metaMessage.from).toBe('923001234567'); + }); +}); + +describe('attach()', () => { + function mockSock() { + const handlers = {}; + return { ev: { on: jest.fn((event, fn) => { handlers[event] = fn; }) }, handlers }; + } + + it('registers a messages.upsert listener and dispatches each mapped message as a synthetic {req,res}', async () => { + jest.resetModules(); + const sock = mockSock(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-channel.service', () => ({ + _cacheIncomingMedia: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-lib', () => ({ loadBaileys: jest.fn().mockResolvedValue({ downloadMediaMessage: jest.fn() }) })); + + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + const dispatch = jest.fn().mockResolvedValue(undefined); + + await adapter.attach(dispatch); + expect(sock.ev.on).toHaveBeenCalledWith('messages.upsert', expect.any(Function)); + + await sock.handlers['messages.upsert']({ + messages: [{ + key: { remoteJid: '923001234567@s.whatsapp.net', id: 'm1', fromMe: false }, + messageTimestamp: 1700000010, + message: { conversation: 'hi bot' }, + }], + }); + + expect(dispatch).toHaveBeenCalledTimes(1); + const [req, res] = dispatch.mock.calls[0]; + expect(req.body.entry[0].changes[0].value.messages[0]).toMatchObject({ from: '923001234567', type: 'text' }); + expect(typeof res.status).toBe('function'); + }); + + it('skips messages that map to null (e.g. group chat) without calling dispatch', async () => { + jest.resetModules(); + const sock = mockSock(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-channel.service', () => ({ _cacheIncomingMedia: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-lib', () => ({ loadBaileys: jest.fn().mockResolvedValue({ downloadMediaMessage: jest.fn() }) })); + + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + const dispatch = jest.fn(); + await adapter.attach(dispatch); + + await sock.handlers['messages.upsert']({ + messages: [{ key: { remoteJid: 'g@g.us', id: 'm2', fromMe: false }, message: { conversation: 'x' } }], + }); + + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('a dispatch failure for one message is logged and does not stop processing the rest', async () => { + jest.resetModules(); + const sock = mockSock(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-channel.service', () => ({ _cacheIncomingMedia: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-lib', () => ({ loadBaileys: jest.fn().mockResolvedValue({ downloadMediaMessage: jest.fn() }) })); + + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + const dispatch = jest.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(undefined); + await adapter.attach(dispatch); + + await sock.handlers['messages.upsert']({ + messages: [ + { key: { remoteJid: '111@s.whatsapp.net', id: 'm3', fromMe: false }, messageTimestamp: 1, message: { conversation: 'a' } }, + { key: { remoteJid: '222@s.whatsapp.net', id: 'm4', fromMe: false }, messageTimestamp: 2, message: { conversation: 'b' } }, + ], + }); + + expect(dispatch).toHaveBeenCalledTimes(2); + }); + + it('dispatches a redelivered message only once — the exact duplicate-delivery bug a live test caught', async () => { + // Real-world discovery: Baileys occasionally redelivers the identical + // message (same key.id) via messages.upsert within under a second — seen + // live for both an image and a document, each fully processed (and + // replied to) twice. This in-memory guard catches it before either + // delivery reaches the network-dependent Redis dedup in + // session.service.js, which has its own race window. + jest.resetModules(); + const sock = mockSock(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-channel.service', () => ({ _cacheIncomingMedia: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-lib', () => ({ loadBaileys: jest.fn().mockResolvedValue({ downloadMediaMessage: jest.fn() }) })); + + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + adapter._resetSeenMessagesForTests(); + const dispatch = jest.fn().mockResolvedValue(undefined); + await adapter.attach(dispatch); + + const redelivered = { + key: { remoteJid: '923001234567@s.whatsapp.net', id: 'dup-msg-1', fromMe: false }, + messageTimestamp: 1700000011, + message: { conversation: 'sent once, delivered twice by Baileys' }, + }; + + await sock.handlers['messages.upsert']({ messages: [redelivered] }); + await sock.handlers['messages.upsert']({ messages: [redelivered] }); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('still dispatches a retry whose FIRST delivery arrived undecrypted — the regression the dedup guard originally caused', async () => { + // Real-world discovery (second live run): Baileys' first delivery attempt + // of a message can fail to decrypt ("No matching sessions") and arrive + // with message:null, then be retried under the SAME key.id once + // decryption succeeds. The dedup guard originally recorded the id on that + // contentless first attempt, so the real decrypted retry was discarded as + // a duplicate and the message was never processed at all — the user saw + // no reply whatsoever. + jest.resetModules(); + const sock = mockSock(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockResolvedValue(sock), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-channel.service', () => ({ _cacheIncomingMedia: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-lib', () => ({ loadBaileys: jest.fn().mockResolvedValue({ downloadMediaMessage: jest.fn() }) })); + + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + adapter._resetSeenMessagesForTests(); + const dispatch = jest.fn().mockResolvedValue(undefined); + await adapter.attach(dispatch); + + const key = { remoteJid: '923001234567@s.whatsapp.net', id: 'retry-msg-1', fromMe: false }; + + // Attempt 1: failed decryption — no content at all. + await sock.handlers['messages.upsert']({ + messages: [{ key, messageTimestamp: 1700000012, message: null }], + }); + expect(dispatch).not.toHaveBeenCalled(); + + // Attempt 2: same id, now decrypted — must be processed, not swallowed. + await sock.handlers['messages.upsert']({ + messages: [{ key, messageTimestamp: 1700000012, message: { conversation: 'finally decrypted' } }], + }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].body.entry[0].changes[0].value.messages[0]) + .toMatchObject({ from: '923001234567', type: 'text', text: { body: 'finally decrypted' } }); + }); +}); + +describe('rememberLidMapping — harvesting @lid->phone from skipped deliveries', () => { + const LID = '111222333444555@lid'; + + function loadWithTmpState() { + const os = require('os'); + const fs = require('fs'); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lidmap-')); + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn(), isConnected: jest.fn(), authDir: () => stateDir, + })); + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + adapter._resetLidCacheForTests(); + return { adapter, stateDir }; + } + + it('harvests the mapping from an UNDECRYPTABLE delivery, so the later decrypted retry resolves correctly', async () => { + // The exact live failure: after a restart the cache is empty. The + // deliveries carrying sender_pn were the ones that FAILED to decrypt, and + // gating the harvest behind hasDispatchableContent() starved the cache — + // the successful retry then had no senderPn, fell back to the LID, and the + // reply went to a non-device JID (reported "sent", never delivered). + const { adapter } = loadWithTmpState(); + + // Undecryptable stub — no content, but it DOES carry senderPn. + adapter.rememberLidMapping({ + key: { remoteJid: LID, id: 'm1', fromMe: false, senderPn: '923001234567@s.whatsapp.net' }, + message: null, + }); + + // The decrypted retry carries NO senderPn — must still resolve to the real number. + const resolved = adapter.resolveSenderPhoneNumber({ + key: { remoteJid: LID, id: 'm1', fromMe: false }, + message: { conversation: 'hi' }, + }); + expect(resolved).toBe('923001234567'); + }); + + it('writes NO map file of its own — Baileys 7.x owns LID persistence', () => { + // This cache is process-local on purpose. Baileys 7.x persists its own + // lid-mapping-*.json files into the auth dir (700+ on a fresh pairing) and + // serves them via lidMapping.getPNForLID(), so the hand-rolled + // lid-to-phone.json this adapter used to write was duplicate machinery and + // has been removed. This map only covers the gap before the native store + // has learned a given mapping. + const fs = require('fs'); + const { adapter, stateDir } = loadWithTmpState(); + + adapter.rememberLidMapping({ key: { remoteJid: LID, senderPn: '923001234567@s.whatsapp.net' } }); + + expect(fs.existsSync(path.join(stateDir, 'lid-to-phone.json'))).toBe(false); + // Still usable in-process as the pre-native-store fallback. + expect(adapter.resolveSenderPhoneNumber({ key: { remoteJid: LID } })).toBe('923001234567'); + }); + + it('prefers Baileys 7.x own LIDMappingStore over the hand-rolled senderPn cache', async () => { + // Baileys 7.x owns LID<->phone mapping properly (sock.signalRepository + // .lidMapping, persisted as lid-mapping-*.json — a fresh pairing wrote 706 + // of them). 6.7.23 had no such store, which is the whole reason this + // adapter had to scrape key.senderPn by hand. The store is authoritative. + const { adapter } = loadWithTmpState(); + const sock = { + signalRepository: { + lidMapping: { getPNForLID: jest.fn().mockResolvedValue('923001234567@s.whatsapp.net') }, + }, + }; + + const resolved = await adapter.resolveSenderPhoneNumberAsync( + { key: { remoteJid: LID, id: 'm1', fromMe: false }, message: { conversation: 'hi' } }, + sock + ); + + expect(sock.signalRepository.lidMapping.getPNForLID).toHaveBeenCalledWith(LID); + expect(resolved).toBe('923001234567'); + }); + + it('falls back to the senderPn cache when the native store has no mapping yet', async () => { + const { adapter } = loadWithTmpState(); + const sock = { + signalRepository: { lidMapping: { getPNForLID: jest.fn().mockResolvedValue(null) } }, + }; + + // Cache warmed from an earlier delivery that did carry senderPn. + adapter.rememberLidMapping({ key: { remoteJid: LID, senderPn: '923001234567@s.whatsapp.net' } }); + + const resolved = await adapter.resolveSenderPhoneNumberAsync({ key: { remoteJid: LID } }, sock); + expect(resolved).toBe('923001234567'); + }); + + it('a throwing native store does not break resolution', async () => { + const { adapter } = loadWithTmpState(); + const sock = { + signalRepository: { lidMapping: { getPNForLID: jest.fn().mockRejectedValue(new Error('store down')) } }, + }; + adapter.rememberLidMapping({ key: { remoteJid: LID, senderPn: '923001234567@s.whatsapp.net' } }); + + await expect(adapter.resolveSenderPhoneNumberAsync({ key: { remoteJid: LID } }, sock)) + .resolves.toBe('923001234567'); + }); + + it('ignores deliveries with no senderPn and non-LID chats', () => { + const fs = require('fs'); + const { adapter, stateDir } = loadWithTmpState(); + + adapter.rememberLidMapping({ key: { remoteJid: LID } }); // LID, no senderPn + adapter.rememberLidMapping({ key: { remoteJid: '923001234567@s.whatsapp.net', senderPn: 'x@s.whatsapp.net' } }); + + expect(fs.existsSync(path.join(stateDir, 'lid-to-phone.json'))).toBe(false); + }); + + it('does not expose or depend on any map-file loader', () => { + // Guards the removal: reintroducing bespoke LID persistence here would + // duplicate what Baileys 7.x already does. + const { adapter } = loadWithTmpState(); + expect(adapter.loadLidCache).toBeUndefined(); + }); +}); + +describe('numeric replies to rendered menus become interactive selections', () => { + // The gap this closes: whatsapp-bot.js dispatches on + // `interactive.button_reply.id` / `list_reply.id` (33 ID families). Baileys + // has no native picker, so the driver renders numbered text — which meant the + // user's "1" arrived as ordinary text, never matched those branches, and fell + // through to general AI chat. Every numbered menu was unanswerable. + function load() { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn(), isConnected: jest.fn(), authDir: jest.fn(), + })); + const adapter = require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + const store = require('../../bot/shared/services/messaging/pending-options'); + store._resetForTests(); + return { adapter, store }; + } + + const key = { remoteJid: '923001234567@s.whatsapp.net', id: 'm1', fromMe: false }; + + it('synthesises a list_reply with the SAME id Metas native picker would have sent', async () => { + const { adapter, store } = load(); + await store.remember('923001234567', { + replyType: 'list_reply', + options: [{ id: 'lang_auto', title: 'Auto-detect' }, { id: 'lang_en', title: 'English' }], + }); + + const result = await adapter.mapToMetaShape( + { key, messageTimestamp: 1700000000, message: { conversation: '2' } }, + jest.fn() + ); + + expect(result.metaMessage).toMatchObject({ + from: '923001234567', + type: 'interactive', + interactive: { type: 'list_reply', list_reply: { id: 'lang_en', title: 'English' } }, + }); + }); + + it('synthesises a button_reply for button menus', async () => { + const { adapter, store } = load(); + await store.remember('923001234567', { + replyType: 'button_reply', + options: [{ id: 'coaching_confirm_42', title: 'Yes' }, { id: 'coaching_cancel_42', title: 'No' }], + }); + + const result = await adapter.mapToMetaShape( + { key, messageTimestamp: 1700000000, message: { conversation: '1' } }, + jest.fn() + ); + + expect(result.metaMessage.interactive).toEqual({ + type: 'button_reply', + button_reply: { id: 'coaching_confirm_42', title: 'Yes' }, + }); + }); + + it('consumes the menu, so the same number is ordinary text the second time', async () => { + const { adapter, store } = load(); + await store.remember('923001234567', { + replyType: 'list_reply', options: [{ id: 'lang_en', title: 'English' }], + }); + + const first = await adapter.mapToMetaShape({ key, messageTimestamp: 1, message: { conversation: '1' } }, jest.fn()); + expect(first.metaMessage.type).toBe('interactive'); + + const second = await adapter.mapToMetaShape( + { key: { ...key, id: 'm2' }, messageTimestamp: 2, message: { conversation: '1' } }, + jest.fn() + ); + expect(second.metaMessage.type).toBe('text'); + }); + + it('leaves ordinary text alone while a menu is pending', async () => { + const { adapter, store } = load(); + await store.remember('923001234567', { + replyType: 'list_reply', options: [{ id: 'lang_en', title: 'English' }], + }); + + const result = await adapter.mapToMetaShape( + { key, messageTimestamp: 1, message: { conversation: 'actually, what can you do?' } }, + jest.fn() + ); + + expect(result.metaMessage.type).toBe('text'); + expect(result.metaMessage.text.body).toBe('actually, what can you do?'); + }); + + it('a bare number with NO pending menu stays plain text', async () => { + const { adapter } = load(); + const result = await adapter.mapToMetaShape( + { key, messageTimestamp: 1, message: { conversation: '1' } }, + jest.fn() + ); + expect(result.metaMessage.type).toBe('text'); + expect(result.metaMessage.text.body).toBe('1'); + }); +}); + +describe('hasDispatchableContent', () => { + function load() { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn(), isConnected: jest.fn(), authDir: jest.fn(), + })); + return require('../../bot/shared/services/messaging/inbound/baileys-socket.adapter'); + } + const key = { remoteJid: '923001234567@s.whatsapp.net', id: 'x', fromMe: false }; + + it('is false for an undecrypted message (message:null) so its id is never recorded', () => { + const { hasDispatchableContent } = load(); + expect(hasDispatchableContent({ key, message: null })).toBe(false); + }); + + it('is false for our own echo and for group chats', () => { + const { hasDispatchableContent } = load(); + expect(hasDispatchableContent({ key: { ...key, fromMe: true }, message: { conversation: 'hi' } })).toBe(false); + expect(hasDispatchableContent({ key: { remoteJid: 'g@g.us', id: 'x' }, message: { conversation: 'hi' } })).toBe(false); + }); + + it('is false for content types the adapter never maps (e.g. sticker)', () => { + const { hasDispatchableContent } = load(); + expect(hasDispatchableContent({ key, message: { stickerMessage: {} } })).toBe(false); + }); + + it('is true for every type mapToMetaShape actually handles', () => { + const { hasDispatchableContent } = load(); + expect(hasDispatchableContent({ key, message: { conversation: 'hi' } })).toBe(true); + expect(hasDispatchableContent({ key, message: { extendedTextMessage: { text: 'hi' } } })).toBe(true); + expect(hasDispatchableContent({ key, message: { imageMessage: {} } })).toBe(true); + expect(hasDispatchableContent({ key, message: { audioMessage: {} } })).toBe(true); + expect(hasDispatchableContent({ key, message: { documentMessage: {} } })).toBe(true); + }); +}); diff --git a/tests/messaging/channel-driver-index.test.js b/tests/messaging/channel-driver-index.test.js new file mode 100644 index 0000000..0c755f0 --- /dev/null +++ b/tests/messaging/channel-driver-index.test.js @@ -0,0 +1,78 @@ +/** + * messaging/index.js — channel driver selector. + * Default (unset, no Meta vars) resolves to the Baileys sandbox driver; + * CHANNEL_DRIVER=meta resolves to the Meta driver; an unknown value falls + * back to Baileys; with no CHANNEL_DRIVER set at all, ANY Meta var already + * present infers `meta` (backward compat for pre-existing deployments). + * Mirrors tests/queue/queue-driver-index.test.js. + */ + +function mockCommon() { + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ + downloadFromR2: jest.fn(), + extractKeyFromUrl: jest.fn(), + })); +} + +const META_VARS = ['CHANNEL_DRIVER', 'WHATSAPP_TOKEN', 'PHONE_NUMBER_ID', 'WEBHOOK_VERIFY_TOKEN', 'WABA_ID']; + +afterEach(() => { + jest.resetModules(); + META_VARS.forEach((k) => delete process.env[k]); +}); + +describe('messaging channel driver selector', () => { + it('defaults to the Baileys singleton when CHANNEL_DRIVER is unset and no Meta vars are present', () => { + jest.resetModules(); + mockCommon(); + const idx = require('../../bot/shared/services/messaging'); + const baileys = require('../../bot/shared/services/messaging/baileys-channel.service'); + expect(idx).toBe(baileys); + }); + + it('returns the Meta singleton when CHANNEL_DRIVER=meta', () => { + jest.resetModules(); + mockCommon(); + process.env.CHANNEL_DRIVER = 'meta'; + const idx = require('../../bot/shared/services/messaging'); + const meta = require('../../bot/shared/services/messaging/meta-channel.service'); + expect(idx).toBe(meta); + }); + + it('returns the Baileys singleton when CHANNEL_DRIVER=baileys explicitly', () => { + jest.resetModules(); + mockCommon(); + process.env.CHANNEL_DRIVER = 'baileys'; + const idx = require('../../bot/shared/services/messaging'); + const baileys = require('../../bot/shared/services/messaging/baileys-channel.service'); + expect(idx).toBe(baileys); + }); + + it('falls back to Baileys for an unknown CHANNEL_DRIVER value', () => { + jest.resetModules(); + mockCommon(); + process.env.CHANNEL_DRIVER = 'telegram'; + const idx = require('../../bot/shared/services/messaging'); + const baileys = require('../../bot/shared/services/messaging/baileys-channel.service'); + expect(idx).toBe(baileys); + }); + + it('with no CHANNEL_DRIVER set, infers Meta when a Meta var is already present (backward compat)', () => { + jest.resetModules(); + mockCommon(); + process.env.WHATSAPP_TOKEN = 'real-looking-token'; + const idx = require('../../bot/shared/services/messaging'); + const meta = require('../../bot/shared/services/messaging/meta-channel.service'); + expect(idx).toBe(meta); + }); + + it('the WhatsAppService facade resolves to the same driver as messaging/index.js', () => { + jest.resetModules(); + mockCommon(); + process.env.CHANNEL_DRIVER = 'baileys'; + const facade = require('../../bot/shared/services/whatsapp.service'); + const baileys = require('../../bot/shared/services/messaging/baileys-channel.service'); + expect(facade).toBe(baileys); + }); +}); diff --git a/tests/messaging/channel-driver-parity.test.js b/tests/messaging/channel-driver-parity.test.js new file mode 100644 index 0000000..2897f21 --- /dev/null +++ b/tests/messaging/channel-driver-parity.test.js @@ -0,0 +1,128 @@ +/** + * Channel driver parity: every driver must expose the same method surface, so + * that WhatsAppService.(...) never throws "is not a function" no + * matter which CHANNEL_DRIVER is active — the exact bug class + * tests/setup/no-undefined-whatsapp-methods.test.js guards against for the + * Meta driver specifically. This test locks the CROSS-DRIVER half of that + * contract. Mirrors tests/queue/queue-driver-parity.test.js. + * + * Method names are parsed independently off meta-channel.service.js's source + * (same regex the guard test uses), NOT via introspection — so this test + * doesn't just tautologically confirm baileys-channel.service.js's own + * derivation mechanism works; it's a second, independent check. + * + * This file only checks EXISTENCE + the still-stubbed methods' behavior. + * Behavior of the REAL (connection-backed) methods is covered by + * tests/messaging/baileys-channel-service.test.js, which mocks + * baileys-connection.js so nothing here ever opens a real socket. + */ + +const fs = require('fs'); +const path = require('path'); + +const META_SERVICE = path.resolve(__dirname, '../../bot/shared/services/messaging/meta-channel.service.js'); + +function parseMethodNames(src) { + const names = new Set(); + const methodRe = /^\s*static\s+(?:async\s+)?(\w+)\s*\(/gm; + let m; + while ((m = methodRe.exec(src))) names.add(m[1]); + return [...names]; +} + +const REQUIRED_METHODS = parseMethodNames(fs.readFileSync(META_SERVICE, 'utf-8')); + +function loadDrivers() { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ + downloadFromR2: jest.fn(), + extractKeyFromUrl: jest.fn(), + })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn().mockRejectedValue(new Error('not connected in this test')), + isConnected: jest.fn().mockReturnValue(false), + authDir: jest.fn().mockReturnValue('/tmp/never-used'), + })); + return { + meta: require('../../bot/shared/services/messaging/meta-channel.service'), + baileys: require('../../bot/shared/services/messaging/baileys-channel.service'), + }; +} + +afterEach(() => jest.resetModules()); + +describe('channel driver parity', () => { + it('parser found the real method names (not vacuously passing)', () => { + expect(REQUIRED_METHODS).toContain('sendMessage'); + expect(REQUIRED_METHODS).toContain('sendFlow'); + expect(REQUIRED_METHODS.length).toBeGreaterThan(20); + }); + + it.each(REQUIRED_METHODS)('Meta driver implements %s()', (m) => { + const { meta } = loadDrivers(); + expect(typeof meta[m]).toBe('function'); + }); + + it.each(REQUIRED_METHODS)('Baileys driver implements %s()', (m) => { + const { baileys } = loadDrivers(); + expect(typeof baileys[m]).toBe('function'); + }); + + // Methods with NO Baileys equivalent (Meta-template-specific — see + // baileys-channel.service.js's STUBS table for why) — these stay honest + // stubs and are safe to call directly without a connection. + const STUB_ASYNC_METHODS = ['sendTemplate', 'sendFlow', 'sendStyleCarousel', 'sendFeatureMenuCarousel']; + const STUB_SYNC_METHODS = ['buildStyleCarouselPayload', 'buildFeatureMenuCarouselPayload']; + + it.each(STUB_ASYNC_METHODS)('Baileys %s() has no equivalent yet — logs and resolves false', async (m) => { + const { baileys } = loadDrivers(); + await expect(baileys[m]('923001234567', 'x', 'y', 'z')).resolves.toBe(false); + }); + + it.each(STUB_SYNC_METHODS)('Baileys %s() has no equivalent yet — is synchronous and returns null, not a Promise', (m) => { + const { baileys } = loadDrivers(); + const result = baileys[m]('923001234567'); + expect(result).not.toBeInstanceOf(Promise); + expect(result).toBeNull(); + }); + + it('Baileys getMediaInfo()/downloadMedia() reject on a cache miss (matches Meta\'s throw-on-failure contract)', async () => { + const { baileys } = loadDrivers(); + await expect(baileys.getMediaInfo('never-cached-id')).rejects.toThrow(/no cached media/); + await expect(baileys.downloadMedia('never-cached-id')).rejects.toThrow(/no cached media/); + }); + + it('Baileys startContinuousTypingIndicator() is synchronous and returns a real, callable controller', () => { + const { baileys } = loadDrivers(); + const controller = baileys.startContinuousTypingIndicator('923001234567', 'msg-id'); + expect(controller).not.toBeInstanceOf(Promise); + expect(typeof controller.stop).toBe('function'); + expect(() => controller.stop()).not.toThrow(); + }); + + it('Baileys _removeEmotionTags() is a real, synchronous reimplementation (pure/channel-agnostic), not a stub', () => { + const { baileys } = loadDrivers(); + expect(baileys._removeEmotionTags('[warmly] hello')).toBe('hello'); + }); + + it('loading the Baileys driver never requires Meta\'s HTTP client (axios/form-data) or meta-channel.service.js itself', () => { + jest.resetModules(); + let axiosRequired = false; + let formDataRequired = false; + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/storage/r2', () => ({ downloadFromR2: jest.fn(), extractKeyFromUrl: jest.fn() })); + jest.doMock('../../bot/shared/services/messaging/baileys-connection', () => ({ + getSocket: jest.fn(), isConnected: jest.fn(), authDir: jest.fn(), + })); + jest.doMock('axios', () => { axiosRequired = true; return {}; }, { virtual: true }); + jest.doMock('form-data', () => { formDataRequired = true; return {}; }, { virtual: true }); + jest.doMock( + '../../bot/shared/services/messaging/meta-channel.service', + () => { throw new Error('baileys-channel.service.js must not require() meta-channel.service.js'); }, + ); + require('../../bot/shared/services/messaging/baileys-channel.service'); + expect(axiosRequired).toBe(false); + expect(formDataRequired).toBe(false); + }); +}); diff --git a/tests/messaging/channel-lifecycle.test.js b/tests/messaging/channel-lifecycle.test.js new file mode 100644 index 0000000..e23ef7d --- /dev/null +++ b/tests/messaging/channel-lifecycle.test.js @@ -0,0 +1,63 @@ +/** + * Process-lifecycle policy for the Baileys channel, as wired in + * bot/whatsapp-bot.js: a logged-out session must be a TERMINAL failure, and + * shutdown must close the socket cleanly. + * + * whatsapp-bot.js binds a port and pulls in the whole bot on require, so these + * tests exercise the two behaviours through the seam they actually depend on — + * baileys-connection.js's `events` emitter and `close()` — rather than by + * booting the server. + */ + +const EXIT_CODE_CHANNEL_LOGGED_OUT = 78; + +describe('logged-out session is terminal, not a restart loop', () => { + it('exits with EX_CONFIG(78) when the channel reports loggedOut', () => { + // baileys-connection.js already refuses to auto-reconnect on 401, but that + // only protects the CURRENT process. systemd/PM2/Docker/Railway all restart + // on exit, and each restart re-attempts pairing against dead credentials — + // an endless loop against WhatsApp's pairing endpoint, which is how live + // testing kept tripping the "can't link new devices right now" limit. + // A distinctive exit code lets a supervisor stop instead of spinning. + const EventEmitter = require('events'); + const events = new EventEmitter(); + const logToFile = jest.fn(); + const exit = jest.fn(); + + // The handler under test, as wired in whatsapp-bot.js#exitOnChannelLogout. + events.on('close', ({ loggedOut }) => { + if (!loggedOut) return; + logToFile('🔒 WhatsApp session is logged out — re-pairing is required, exiting', {}); + exit(EXIT_CODE_CHANNEL_LOGGED_OUT); + }); + + events.emit('close', { statusCode: 428, loggedOut: false }); + expect(exit).not.toHaveBeenCalled(); // an ordinary drop must NOT be terminal + + events.emit('close', { statusCode: 401, loggedOut: true }); + expect(exit).toHaveBeenCalledWith(EXIT_CODE_CHANNEL_LOGGED_OUT); + expect(logToFile.mock.calls[0][0]).toMatch(/logged out/i); + }); + + it('whatsapp-bot.js actually wires the logout handler and uses code 78', () => { + // Guards the wiring itself: the behaviour above is worthless if + // exitOnChannelLogout() is never registered from startServer(). + const fs = require('fs'); + const path = require('path'); + const src = fs.readFileSync(path.resolve(__dirname, '../../bot/whatsapp-bot.js'), 'utf-8'); + + expect(src).toMatch(/EXIT_CODE_CHANNEL_LOGGED_OUT\s*=\s*78/); + expect(src).toMatch(/function exitOnChannelLogout\s*\(/); + expect(src).toMatch(/exitOnChannelLogout\(\)/); + expect(src).toMatch(/registerChannelShutdownHandlers\(\)/); + }); + + it('the logout handler is a no-op for the meta driver, which has no local session', () => { + const src = require('fs').readFileSync( + require('path').resolve(__dirname, '../../bot/whatsapp-bot.js'), + 'utf-8' + ); + const fn = src.slice(src.indexOf('function exitOnChannelLogout')); + expect(fn).toMatch(/resolveChannelDriver\(process\.env\) !== 'baileys'\) return/); + }); +}); diff --git a/tests/messaging/channel-registry.test.js b/tests/messaging/channel-registry.test.js new file mode 100644 index 0000000..fd9f438 --- /dev/null +++ b/tests/messaging/channel-registry.test.js @@ -0,0 +1,29 @@ +/** + * channel-registry — pure data, no env/process logic. Confirms the shape the + * rest of the messaging module (and feature-availability.js) builds on: a + * default-sandbox rule with an explicit production allowlist, not a per-driver + * tag someone has to remember to set. + */ + +const registry = require('../../bot/shared/services/messaging/channel-registry'); + +describe('channel-registry', () => { + it('lists meta and baileys as the v1 drivers, defaulting to baileys', () => { + expect(Object.keys(registry.DRIVERS).sort()).toEqual(['baileys', 'meta']); + expect(registry.DEFAULT_DRIVER).toBe('baileys'); + }); + + it('meta is the only production-tier driver — everything else is sandbox by default', () => { + expect(registry.PRODUCTION_TIER_DRIVERS).toEqual(['meta']); + expect(registry.isProductionTier('meta')).toBe(true); + expect(registry.isProductionTier('baileys')).toBe(false); + // A hypothetical future driver not yet on the allowlist is sandbox by default. + expect(registry.isProductionTier('slack')).toBe(false); + }); + + it('isKnownDriver reflects exactly the DRIVERS map', () => { + expect(registry.isKnownDriver('meta')).toBe(true); + expect(registry.isKnownDriver('baileys')).toBe(true); + expect(registry.isKnownDriver('telegram')).toBe(false); + }); +}); diff --git a/tests/messaging/endpoint-text-flow.test.js b/tests/messaging/endpoint-text-flow.test.js new file mode 100644 index 0000000..378b9a6 --- /dev/null +++ b/tests/messaging/endpoint-text-flow.test.js @@ -0,0 +1,292 @@ +/** + * endpoint-text-flow.js — drives a real WhatsApp Flow ENDPOINT over chat. + * + * The endpoint contract under test: + * INIT -> { screen, data: { , ...values } } + * data_exchange(screen, data) -> same, or { data: { error: { message } } } + * + * The point of these tests is that the endpoint is called the way a Flow client + * would call it — right screen, accumulated screenData — and that its side + * effects happen EXACTLY ONCE, on submission. + */ + +function load() { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/services/cache/railway-redis.service', () => ({ + set: jest.fn(async () => false), get: jest.fn(async () => null), delete: jest.fn(async () => false), + })); + const textFlow = require('../../bot/shared/services/messaging/text-flow'); + const { buildEndpointFlow } = require('../../bot/shared/services/messaging/endpoint-text-flow'); + const pending = require('../../bot/shared/services/messaging/pending-options'); + textFlow._resetForTests(); + pending._resetForTests(); + return { textFlow, buildEndpointFlow, pending }; +} + +const PHONE = '923001234567'; +const CTX = { _ctx: { userId: 'user-1', flowToken: 'user-1:videos:9', phone: PHONE } }; + +/** A stand-in for student-videos-endpoint.js: 3 screens, last one "sends". */ +function fakeVideoEndpoint() { + const calls = []; + return { + calls, + init: async (ctx) => { + calls.push(['INIT', ctx.flowToken]); + return { screen: 'SELECT_GRADE', data: { grades: [{ id: '1', title: 'Grade 1' }, { id: '2', title: 'Grade 2' }] } }; + }, + exchange: async (ctx, screen, screenData) => { + calls.push([screen, { ...screenData }]); + if (screen === 'SELECT_GRADE') { + return { screen: 'SELECT_SUBJECT', data: { subjects: [{ id: 'maths', title: 'Maths' }, { id: 'sci', title: 'Science' }] } }; + } + if (screen === 'SELECT_SUBJECT') { + return { + screen: 'SELECT_TOPIC', + data: { videos: [{ id: 'v9', title: 'Life Cycles · Butterfly' }], header_text: 'Grade 2 — Science' }, + }; + } + return { screen: 'SUCCESS', data: { message: 'on its way' } }; + }, + }; +} + +function videoFlow(endpoint, buildEndpointFlow) { + return buildEndpointFlow({ + kind: 'student-videos', + init: endpoint.init, + exchange: endpoint.exchange, + stages: [ + { screen: 'SELECT_GRADE', fields: [{ id: 'grade', optionsKey: 'grades', prompt: () => ({ body: 'Which class?' }) }] }, + { screen: 'SELECT_SUBJECT', fields: [{ id: 'subject', optionsKey: 'subjects' }] }, + { + screen: 'SELECT_TOPIC', + fields: [{ + id: 'video', + optionsKey: 'videos', + prompt: (a, c) => ({ header: c.response?.data?.header_text }), + }], + }, + ], + onFinish: (res) => res.data.message, + }); +} + +afterEach(() => jest.resetModules()); + +describe('driving a 3-screen endpoint over chat', () => { + it('asks each screen in turn, accumulating screenData like a Flow client', async () => { + const { textFlow, buildEndpointFlow } = load(); + const endpoint = fakeVideoEndpoint(); + textFlow.register(videoFlow(endpoint, buildEndpointFlow)); + + const first = await textFlow.start(PHONE, 'student-videos', {}, CTX); + expect(first.kind).toBe('menu'); + expect(first.prompt.body).toBe('Which class?'); + expect(first.options.map((o) => o.title)).toEqual(['Grade 1', 'Grade 2']); + + const second = await textFlow.advance(PHONE, '2'); + expect(second.status).toBe('step'); + expect(second.render.options.map((o) => o.id)).toEqual(['maths', 'sci']); + + const third = await textFlow.advance(PHONE, 'Science'); + expect(third.render.prompt.header).toBe('Grade 2 — Science'); + + // INIT, then one data_exchange per completed screen — with the answers so + // far, keyed by field id, exactly as the Flow client submits them. + expect(endpoint.calls).toEqual([ + ['INIT', 'user-1:videos:9'], + ['SELECT_GRADE', { grade: '2' }], + ['SELECT_SUBJECT', { grade: '2', subject: 'sci' }], + ]); + }); + + it('submits the final screen exactly once, on completion', async () => { + const { textFlow, buildEndpointFlow } = load(); + const endpoint = fakeVideoEndpoint(); + const definition = videoFlow(endpoint, buildEndpointFlow); + textFlow.register(definition); + + await textFlow.start(PHONE, 'student-videos', {}, CTX); + await textFlow.advance(PHONE, '2'); + await textFlow.advance(PHONE, 'Science'); + const done = await textFlow.advance(PHONE, 'butterfly'); // substring match + + expect(done.status).toBe('complete'); + const outcome = await definition.onComplete(PHONE, done.answers, done.context); + expect(outcome.text).toBe('on its way'); + + const submissions = endpoint.calls.filter(([screen]) => screen === 'SELECT_TOPIC'); + expect(submissions).toEqual([['SELECT_TOPIC', { grade: '2', subject: 'sci', video: 'v9' }]]); + }); + + it('never replays an earlier screen while rendering a later one', async () => { + // Load-bearing: student-videos' final data_exchange SENDS A VIDEO. If + // rendering step N recomputed the endpoint chain from scratch, a re-render + // (a mistyped answer, say) would fire real side effects again. + const { textFlow, buildEndpointFlow } = load(); + const endpoint = fakeVideoEndpoint(); + textFlow.register(videoFlow(endpoint, buildEndpointFlow)); + + await textFlow.start(PHONE, 'student-videos', {}, CTX); + await textFlow.advance(PHONE, '2'); + await textFlow.advance(PHONE, 'Science'); + + const perScreen = endpoint.calls.map(([screen]) => screen); + expect(perScreen.filter((s) => s === 'INIT')).toHaveLength(1); + expect(perScreen.filter((s) => s === 'SELECT_GRADE')).toHaveLength(1); + }); +}); + +describe('several fields on ONE screen (the settings shape)', () => { + function settingsLike(buildEndpointFlow, exchange) { + return buildEndpointFlow({ + kind: 'settings', + init: async () => ({ + screen: 'SETTINGS_MAIN', + data: { + languages: [{ id: 'en', title: 'English' }, { id: 'ur', title: 'Urdu' }], + frameworks: [{ id: 'oecd', title: 'OECD' }, { id: 'teach', title: 'TEACH' }], + info_text: 'Default for Pakistan: TEACH.', + }, + }), + exchange, + stages: [{ + screen: 'SETTINGS_MAIN', + fields: [ + { id: 'language', optionsKey: 'languages' }, + { id: 'observation_framework', optionsKey: 'frameworks', prompt: (a, c) => ({ footer: c.response.data.info_text }) }, + ], + }], + onFinish: (res) => res.data.confirmation_message, + }); + } + + it('asks one question per field, then submits both together', async () => { + const { textFlow, buildEndpointFlow } = load(); + const exchange = jest.fn(async () => ({ screen: 'SUCCESS', data: { confirmation_message: 'Saved.' } })); + const definition = settingsLike(buildEndpointFlow, exchange); + textFlow.register(definition); + + const first = await textFlow.start(PHONE, 'settings', {}, CTX); + expect(first.options.map((o) => o.id)).toEqual(['en', 'ur']); + + const second = await textFlow.advance(PHONE, 'Urdu'); + expect(second.render.options.map((o) => o.id)).toEqual(['oecd', 'teach']); + expect(second.render.prompt.footer).toBe('Default for Pakistan: TEACH.'); + // The second field of the same screen must NOT trigger a submission. + expect(exchange).not.toHaveBeenCalled(); + + const done = await textFlow.advance(PHONE, 'TEACH'); + const outcome = await definition.onComplete(PHONE, done.answers, done.context); + + expect(exchange).toHaveBeenCalledTimes(1); + expect(exchange.mock.calls[0][1]).toBe('SETTINGS_MAIN'); + expect(exchange.mock.calls[0][2]).toEqual({ language: 'ur', observation_framework: 'teach' }); + expect(outcome.text).toBe('Saved.'); + }); +}); + +describe('endpoint failures reach the user instead of crashing', () => { + it("shows the endpoint's own error message when a screen has no rows", async () => { + const { textFlow, buildEndpointFlow } = load(); + textFlow.register(buildEndpointFlow({ + kind: 'empty-lib', + init: async () => ({ data: { error: { message: 'The video library is being prepared.' } } }), + exchange: async () => ({}), + stages: [{ screen: 'A', fields: [{ id: 'x', optionsKey: 'rows' }] }], + })); + + const first = await textFlow.start(PHONE, 'empty-lib', {}, CTX); + + expect(first.kind).toBe('empty'); + expect(first.prompt.body).toBe('The video library is being prepared.'); + // and the user is not parked in an unanswerable flow + await expect(textFlow.isActive(PHONE)).resolves.toBe(false); + }); + + it('a thrown endpoint becomes the configured fallback message, not an exception', async () => { + const { textFlow, buildEndpointFlow } = load(); + textFlow.register(buildEndpointFlow({ + kind: 'boom', + init: async () => { throw new Error('supabase down'); }, + exchange: async () => ({}), + fallbackError: 'Not available right now.', + stages: [{ screen: 'A', fields: [{ id: 'x', optionsKey: 'rows' }] }], + })); + + const first = await textFlow.start(PHONE, 'boom', {}, CTX); + expect(first.kind).toBe('empty'); + expect(first.prompt.body).toBe('Not available right now.'); + }); + + it('a thrown FINAL exchange is reported, not swallowed as success', async () => { + const { textFlow, buildEndpointFlow } = load(); + const definition = buildEndpointFlow({ + kind: 'boom-submit', + init: async () => ({ screen: 'A', data: { rows: [{ id: 'r1', title: 'Row one' }] } }), + exchange: async () => { throw new Error('write failed'); }, + fallbackError: 'Could not save that.', + stages: [{ screen: 'A', fields: [{ id: 'x', optionsKey: 'rows' }] }], + }); + textFlow.register(definition); + + await textFlow.start(PHONE, 'boom-submit', {}, CTX); + const done = await textFlow.advance(PHONE, 'Row one'); + const outcome = await definition.onComplete(PHONE, done.answers, done.context); + + expect(outcome.text).toBe('Could not save that.'); + }); + + it("surfaces a validation error the endpoint returns from the submission", async () => { + const { textFlow, buildEndpointFlow } = load(); + const definition = buildEndpointFlow({ + kind: 'rejects', + init: async () => ({ screen: 'A', data: { rows: [{ id: 'r1', title: 'Row one' }] } }), + exchange: async () => ({ data: { error: { message: 'Invalid observation framework' } } }), + stages: [{ screen: 'A', fields: [{ id: 'x', optionsKey: 'rows' }] }], + onFinish: () => 'should not be used', + }); + textFlow.register(definition); + + await textFlow.start(PHONE, 'rejects', {}, CTX); + const done = await textFlow.advance(PHONE, 'Row one'); + + expect((await definition.onComplete(PHONE, done.answers, done.context)).text) + .toBe('Invalid observation framework'); + }); +}); + +describe('config validation', () => { + it('rejects a config missing its endpoint functions', () => { + const { buildEndpointFlow } = load(); + expect(() => buildEndpointFlow({ kind: 'x', stages: [{ screen: 'A', fields: [] }] })) + .toThrow(/needs \{ kind, init, exchange, stages/); + }); +}); + +describe('row normalisation', () => { + it('drops rows a numbered menu could not offer, and coerces ids to strings', () => { + const { buildEndpointFlow } = load(); + const { rowsFrom } = require('../../bot/shared/services/messaging/endpoint-text-flow'); + expect(buildEndpointFlow).toBeDefined(); + expect(rowsFrom({ data: { rows: [ + { id: 3, title: 'Grade 3' }, + { id: '', title: 'no id' }, + { id: 'x' }, // no title -> falls back to the id + 'plain', + ] } }, 'rows')).toEqual([ + { id: '3', title: 'Grade 3' }, + { id: 'x', title: 'x' }, + { id: 'plain', title: 'plain' }, + ]); + }); + + it('returns nothing for a key the endpoint did not send', () => { + load(); + const { rowsFrom } = require('../../bot/shared/services/messaging/endpoint-text-flow'); + expect(rowsFrom({ data: {} }, 'rows')).toEqual([]); + expect(rowsFrom(null, 'rows')).toEqual([]); + }); +}); diff --git a/tests/messaging/pending-options.test.js b/tests/messaging/pending-options.test.js new file mode 100644 index 0000000..40e2dad --- /dev/null +++ b/tests/messaging/pending-options.test.js @@ -0,0 +1,295 @@ +/** + * pending-options.js — the store that makes numbered menus answerable on the + * Baileys driver. + * + * Redis is stubbed to fail so the in-memory fallback path is what runs here + * (and so no real connection is opened); a separate test asserts the Redis path + * is used when it works. + */ + +function loadStore({ redisImpl } = {}) { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + const redis = redisImpl || { + set: jest.fn().mockRejectedValue(new Error('redis down')), + get: jest.fn().mockRejectedValue(new Error('redis down')), + delete: jest.fn().mockRejectedValue(new Error('redis down')), + }; + jest.doMock('../../bot/shared/services/cache/railway-redis.service', () => redis); + const store = require('../../bot/shared/services/messaging/pending-options'); + store._resetForTests(); + return { store, redis }; +} + +const MENU = { + replyType: 'list_reply', + options: [ + { id: 'lang_auto', title: 'Auto-detect' }, + { id: 'lang_en', title: 'English' }, + { id: 'lang_ur', title: 'اردو' }, + ], +}; + +afterEach(() => jest.resetModules()); + +describe('resolveSelection', () => { + it('maps a bare in-range number to the option at that position', () => { + const { store } = loadStore(); + expect(store.resolveSelection(MENU, '1')).toEqual({ id: 'lang_auto', title: 'Auto-detect' }); + expect(store.resolveSelection(MENU, '2')).toEqual({ id: 'lang_en', title: 'English' }); + expect(store.resolveSelection(MENU, '3')).toEqual({ id: 'lang_ur', title: 'اردو' }); + }); + + it('tolerates surrounding whitespace', () => { + const { store } = loadStore(); + expect(store.resolveSelection(MENU, ' 2 ')).toEqual({ id: 'lang_en', title: 'English' }); + }); + + it('rejects out-of-range numbers rather than guessing', () => { + const { store } = loadStore(); + expect(store.resolveSelection(MENU, '0')).toBeNull(); + expect(store.resolveSelection(MENU, '4')).toBeNull(); + expect(store.resolveSelection(MENU, '99')).toBeNull(); + }); + + it('rejects prose and empty input — a pending menu does not mean the user is answering it', () => { + // The user may simply be saying something else while a menu is open; + // treating that as a selection would hijack normal conversation. + const { store } = loadStore(); + expect(store.resolveSelection(MENU, 'what can you do?')).toBeNull(); + expect(store.resolveSelection(MENU, '')).toBeNull(); + expect(store.resolveSelection(MENU, 'tell me about lesson plans')).toBeNull(); + }); +}); + +describe('resolveSelection — cases found by live testing', () => { + // A teacher's classes are literally NAMED with digits. Replying "5" to + // ["4 - B", "5"] means the class called 5, not item five of two — and treating + // it as an out-of-range position sent the reply to general AI chat, leaving + // /quiz stalled with no explanation. + const CLASSES = { + replyType: 'list_reply', + options: [ + { id: 'cls_a', title: '4 - B', description: 'Tap to select this class' }, + { id: 'cls_b', title: '5', description: 'Tap to select this class' }, + ], + }; + + it('falls back to an exact NAME match when a number is out of range', () => { + const { store } = loadStore(); + expect(store.resolveSelection(CLASSES, '5')).toEqual(expect.objectContaining({ id: 'cls_b' })); + }); + + it('still prefers the POSITION when the number is in range', () => { + // "2" is what the rendered "2. 5" line asked for, so position wins. + const { store } = loadStore(); + expect(store.resolveSelection(CLASSES, '2')).toEqual(expect.objectContaining({ id: 'cls_b' })); + expect(store.resolveSelection(CLASSES, '1')).toEqual(expect.objectContaining({ id: 'cls_a' })); + }); + + it('matches a class named "4" by its first word, not by position', () => { + const { store } = loadStore(); + expect(store.resolveSelection(CLASSES, '4')).toEqual(expect.objectContaining({ id: 'cls_a' })); + }); + + it('a short NON-numeric reply still matches nothing (too collision-prone)', () => { + const { store } = loadStore(); + expect(store.resolveSelection(CLASSES, 'ok')).toBeNull(); + expect(store.resolveSelection(CLASSES, 'hi')).toBeNull(); + }); + + it('an out-of-range number matching no name is still nothing', () => { + const { store } = loadStore(); + expect(store.resolveSelection(CLASSES, '9')).toBeNull(); + }); + + // The video picker renders "Chapter · Title"; a teacher types the part that + // names the video, which is the half after the separator. + const VIDEOS = { + replyType: 'list_reply', + options: [ + { id: 'v1', title: 'Life Cycles of Living Things · Life Cycle of a Butterfly' }, + { id: 'v2', title: 'Life Cycles of Living Things · Life Cycle of a Frog' }, + { id: 'v3', title: 'Inventors and Inventions · Who Invented the Radio?' }, + ], + }; + + it('matches the identifying half of a "Group · Item" label', () => { + const { store } = loadStore(); + expect(store.resolveSelection(VIDEOS, 'Life Cycle of a Frog')).toEqual(expect.objectContaining({ id: 'v2' })); + }); + + it('matches a distinctive substring rather than demanding the whole title', () => { + const { store } = loadStore(); + expect(store.resolveSelection(VIDEOS, 'butterfly')).toEqual(expect.objectContaining({ id: 'v1' })); + expect(store.resolveSelection(VIDEOS, 'radio')).toEqual(expect.objectContaining({ id: 'v3' })); + }); + + it('refuses to guess when a substring matches several options', () => { + const { store } = loadStore(); + // "life cycle" is in both v1 and v2 — ambiguity must resolve to nothing. + expect(store.resolveSelection(VIDEOS, 'life cycle')).toBeNull(); + }); + + it('does not let a 3-char substring match (that tier needs 4+)', () => { + const { store } = loadStore(); + // "rad" is a substring of "Radio" but not a prefix of any label. + expect(store.resolveSelection(VIDEOS, 'rad')).toBeNull(); + }); +}); + +describe('resolveSelection by NAME (typing a number is unrealistic)', () => { + // People shown a language list naturally type "Urdu", not "3". + const LANGS = { + replyType: 'list_reply', + options: [ + { id: 'lang_auto', title: 'Auto-detect', description: 'Let me detect your language automatically' }, + { id: 'lang_en', title: 'English', description: 'English language' }, + { id: 'lang_ur', title: 'اردو', description: 'Urdu language' }, + { id: 'lang_es', title: 'Español', description: 'Spanish' }, + ], + }; + + it('matches an exact title, case-insensitively', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, 'English').id).toBe('lang_en'); + expect(store.resolveSelection(LANGS, 'english').id).toBe('lang_en'); + expect(store.resolveSelection(LANGS, ' ENGLISH ').id).toBe('lang_en'); + }); + + it('matches a non-Latin title by its Latin gloss — the whole point of keeping the description', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, 'Urdu').id).toBe('lang_ur'); // first word of description + expect(store.resolveSelection(LANGS, 'urdu language').id).toBe('lang_ur'); // full description + expect(store.resolveSelection(LANGS, 'اردو').id).toBe('lang_ur'); // the title itself + }); + + it('matches the non-Latin title directly, and an exact description', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, 'Spanish').id).toBe('lang_es'); + }); + + it('accepts a UNIQUE prefix of 3+ chars', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, 'eng').id).toBe('lang_en'); + expect(store.resolveSelection(LANGS, 'auto').id).toBe('lang_auto'); + }); + + it('refuses to guess when a prefix is ambiguous', () => { + const { store } = loadStore(); + const ambiguous = { + replyType: 'list_reply', + options: [{ id: 'a', title: 'Reading' }, { id: 'b', title: 'Reading Assessment' }], + }; + // "read" prefixes both — better to fall through than pick wrong. + expect(store.resolveSelection(ambiguous, 'read')).toBeNull(); + }); + + it('ignores 1-2 char text as too collision-prone', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, 'en')).toBeNull(); + expect(store.resolveSelection(LANGS, 'e')).toBeNull(); + }); + + it('still prefers a numeric answer', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, '3').id).toBe('lang_ur'); + }); + + it('tolerates trailing punctuation', () => { + const { store } = loadStore(); + expect(store.resolveSelection(LANGS, 'English.').id).toBe('lang_en'); + expect(store.resolveSelection(LANGS, 'english!').id).toBe('lang_en'); + }); + + it('is null-safe with no pending menu', () => { + const { store } = loadStore(); + expect(store.resolveSelection(null, '1')).toBeNull(); + expect(store.resolveSelection({ options: [] }, '1')).toBeNull(); + expect(store.resolveSelection(MENU, undefined)).toBeNull(); + }); +}); + +describe('remember / get / clear', () => { + it('round-trips a menu through the in-memory fallback when Redis is down', async () => { + const { store } = loadStore(); + await store.remember('923001234567', MENU); + await expect(store.get('923001234567')).resolves.toEqual(MENU); + }); + + it('prefers Redis when it works, and writes with a TTL', async () => { + const redis = { + set: jest.fn().mockResolvedValue('OK'), + get: jest.fn().mockResolvedValue(JSON.stringify(MENU)), + delete: jest.fn().mockResolvedValue(1), + }; + const { store } = loadStore({ redisImpl: redis }); + + await store.remember('923001234567', MENU); + expect(redis.set).toHaveBeenCalledWith( + 'baileys:pending-options:923001234567', + JSON.stringify({ replyType: MENU.replyType, options: MENU.options }), + store.TTL_SECONDS + ); + + await expect(store.get('923001234567')).resolves.toEqual(MENU); + }); + + it('clear() removes the menu so the same number cannot be replayed', async () => { + const { store } = loadStore(); + await store.remember('923001234567', MENU); + await store.clear('923001234567'); + await expect(store.get('923001234567')).resolves.toBeNull(); + }); + + it('keeps menus per-user', async () => { + const { store } = loadStore(); + await store.remember('923001234567', MENU); + await expect(store.get('923009999999')).resolves.toBeNull(); + }); + + it('a later menu replaces the earlier one for the same user', async () => { + const { store } = loadStore(); + const second = { replyType: 'button_reply', options: [{ id: 'coaching_confirm_1', title: 'Yes' }] }; + await store.remember('923001234567', MENU); + await store.remember('923001234567', second); + await expect(store.get('923001234567')).resolves.toEqual(second); + }); + + it('ignores empty menus and missing phone numbers', async () => { + const { store } = loadStore(); + await store.remember('923001234567', { replyType: 'list_reply', options: [] }); + await expect(store.get('923001234567')).resolves.toBeNull(); + await expect(store.get('')).resolves.toBeNull(); + }); + + it('warns when Redis silently declines the write (set() returns false, does not throw)', async () => { + // railway-redis.service.set() returns FALSE when Redis isn't ready rather + // than throwing, so a try/catch alone sees success. Without this check an + // unpersisted menu is invisible until a user's numeric reply mysteriously + // does nothing after a restart. + const logToFile = jest.fn(); + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile })); + jest.doMock('../../bot/shared/services/cache/railway-redis.service', () => ({ + set: jest.fn().mockResolvedValue(false), // the silent-decline shape + get: jest.fn().mockResolvedValue(null), + delete: jest.fn().mockResolvedValue(false), + })); + const store = require('../../bot/shared/services/messaging/pending-options'); + store._resetForTests(); + + await store.remember('923001234567', MENU); + + expect(logToFile.mock.calls.some(([msg]) => /Redis unavailable/i.test(msg))).toBe(true); + // …and the menu still works in-process. + await expect(store.get('923001234567')).resolves.toEqual(MENU); + }); + + it('a Redis failure never throws out of remember/get/clear', async () => { + const { store } = loadStore(); + await expect(store.remember('923001234567', MENU)).resolves.toBeUndefined(); + await expect(store.get('923001234567')).resolves.toEqual(MENU); + await expect(store.clear('923001234567')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/messaging/text-flow-definitions.test.js b/tests/messaging/text-flow-definitions.test.js new file mode 100644 index 0000000..a155851 --- /dev/null +++ b/tests/messaging/text-flow-definitions.test.js @@ -0,0 +1,218 @@ +/** + * text-flow-definitions.js — the text stand-ins for this deployment's Flows. + * + * The reading-assessment definition is a CONTRACT, not just a questionnaire: it + * synthesises the same `nfm_reply` webhook a real Meta Flow submits, so + * whatsapp-bot.js's dispatch and flow-response.handler.js run unchanged. These + * tests pin the field names and value formats against the REAL consumers + * (flow-type-detector.js, and the parsing in flow-response.handler.js), so a + * rename on either side fails here instead of at runtime on a live deployment. + */ + +const path = require('path'); +const fs = require('fs'); + +// HOISTED on purpose. These route modules pull in supabase, and the endpoint- +// backed definitions require them lazily *inside* their builders — so a +// doMock() registered later in load() was missed under a full-suite run and the +// tests hit the real database (7s timeouts). jest.mock is hoisted above every +// require in this file, which cannot be missed. +jest.mock('../../bot/shared/routes/student-videos-endpoint', () => ({ + handleStudentVideosInit: jest.fn(async () => ({ screen: 'SELECT_GRADE', data: { grades: [{ id: '1', title: 'Grade 1' }] } })), + handleStudentVideosDataExchange: jest.fn(async () => ({ screen: 'SUCCESS', data: {} })), +})); +jest.mock('../../bot/shared/routes/settings-endpoint', () => ({ + handleSettingsInit: jest.fn(async () => ({ screen: 'SETTINGS_MAIN', data: { languages: [{ id: 'en', title: 'English' }], frameworks: [{ id: 'oecd', title: 'OECD' }] } })), + handleSettingsDataExchange: jest.fn(async () => ({ screen: 'SUCCESS', data: { confirmation_message: 'Saved.' } })), +})); +jest.mock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); +jest.mock('../../bot/shared/services/cache/railway-redis.service', () => ({ + set: jest.fn(async () => false), get: jest.fn(async () => null), delete: jest.fn(async () => false), +})); + +function load() { + jest.clearAllMocks(); + const definitions = require('../../bot/shared/services/messaging/text-flow-definitions'); + const textFlow = require('../../bot/shared/services/messaging/text-flow'); + const pending = require('../../bot/shared/services/messaging/pending-options'); + textFlow._resetForTests(); + pending._resetForTests(); + definitions._resetForTests(); + definitions.ensureRegistered(); + return { definitions, textFlow, pending }; +} + +const PHONE = '923001234567'; +const CTX = { _ctx: { userId: 'user-7', flowToken: 'user-7:reading-assessment:1', phone: PHONE } }; + +afterEach(() => jest.resetModules()); + +/** Walks a flow to completion, answering each step with the given replies. */ +async function run(textFlow, kind, replies, ctx = CTX) { + await textFlow.start(PHONE, kind, {}, ctx); + let last; + for (const reply of replies) { + last = await textFlow.advance(PHONE, reply); + if (last?.status === 'unmatched') throw new Error(`"${reply}" did not answer the current step`); + } + return last; +} + +describe('registration', () => { + it('registers the three flows a sandbox needs, and is idempotent', () => { + const { definitions, textFlow } = load(); + for (const kind of ['student-videos', 'settings', 'reading-assessment']) { + expect(textFlow.getDefinition(kind)).toBeTruthy(); + } + definitions.ensureRegistered(); // second call must not throw or duplicate + expect(textFlow.getDefinition('settings').kind).toBe('settings'); + }); +}); + +describe('reading-assessment: the synthesised submission is routable', () => { + it('produces an nfm_reply that flow-type-detector routes to reading_assessment', async () => { + const { textFlow } = load(); + const definition = textFlow.getDefinition('reading-assessment'); + + const done = await run(textFlow, 'reading-assessment', [ + 'Aisha Khan', 'English', 'Choose the level myself', 'Sentences', 'Fluency + Comprehension', + ]); + expect(done.status).toBe('complete'); + + const { metaMessage } = await definition.onComplete(PHONE, done.answers, done.context); + expect(metaMessage.type).toBe('interactive'); + expect(metaMessage.interactive.type).toBe('nfm_reply'); + + const responseJson = JSON.parse(metaMessage.interactive.nfm_reply.response_json); + const { detectFlowType } = require('../../bot/shared/utils/flow-type-detector'); + expect(detectFlowType(responseJson)).toBe('reading_assessment'); + }); + + it('carries the exact fields+formats flow-response.handler.js parses', async () => { + const { textFlow } = load(); + const definition = textFlow.getDefinition('reading-assessment'); + + const done = await run(textFlow, 'reading-assessment', [ + 'Aisha Khan', 'Urdu', 'Choose the level myself', 'Words', 'Fluency only', + ]); + const { metaMessage } = await definition.onComplete(PHONE, done.answers, done.context); + const responseJson = JSON.parse(metaMessage.interactive.nfm_reply.response_json); + + expect(responseJson.Student_Full_Name).toBe('Aisha Khan'); + // "index_Label": the handler splits on "_" and matches the label, mapping + // anything that isn't English to 'ur'. + expect(responseJson.Language).toBe('1_Urdu'); + expect(responseJson.Assessment_Mode).toBe('1_Manual'); + // The handler reads the LEADING INDEX to pick the passage type + // (0 letters, 1 words, 2 sentences, 3 paragraph). + expect(responseJson.Select_the_reading_level).toMatch(/^1_/); + // Comprehension is decided by whether the scope value CONTAINS "Comprehension". + expect(responseJson.Scope_of_Assessment_).toBe('0_Fluency_Only'); + expect(responseJson.Scope_of_Assessment_).not.toMatch(/Comprehension/); + }); + + it('marks comprehension as required when the teacher asks for it', async () => { + const { textFlow } = load(); + const definition = textFlow.getDefinition('reading-assessment'); + const done = await run(textFlow, 'reading-assessment', [ + 'Bilal', 'English', 'Automatic', 'Fluency + Comprehension', + ]); + const { metaMessage } = await definition.onComplete(PHONE, done.answers, done.context); + const responseJson = JSON.parse(metaMessage.interactive.nfm_reply.response_json); + expect(responseJson.Scope_of_Assessment_).toMatch(/Comprehension/); + }); + + it('skips the level question in automatic mode, but still sends a parseable level', async () => { + // The handler ignores the level in auto mode (it starts at story and adapts), + // so asking would be a question with no effect — but the field must still + // parse, because its absence is treated as a missing required field. + const { textFlow } = load(); + const definition = textFlow.getDefinition('reading-assessment'); + + await textFlow.start(PHONE, 'reading-assessment', {}, CTX); + await textFlow.advance(PHONE, 'Bilal'); + await textFlow.advance(PHONE, 'English'); + const afterMode = await textFlow.advance(PHONE, 'Automatic'); + + // straight to scope — the level step was skipped + expect(afterMode.status).toBe('step'); + expect(afterMode.render.options.map((o) => o.title)).toEqual( + expect.arrayContaining(['Fluency only', 'Fluency + Comprehension']) + ); + + const done = await textFlow.advance(PHONE, 'Fluency only'); + expect(done.status).toBe('complete'); + expect(done.answers.Select_the_reading_level).toBeUndefined(); + + const { metaMessage } = await definition.onComplete(PHONE, done.answers, done.context); + const responseJson = JSON.parse(metaMessage.interactive.nfm_reply.response_json); + expect(responseJson.Select_the_reading_level).toMatch(/^\d+_/); + }); + + it('accepts the student name as free text (any name, not a menu pick)', async () => { + const { textFlow } = load(); + const first = await textFlow.start(PHONE, 'reading-assessment', {}, CTX); + expect(first.kind).toBe('text'); + + const next = await textFlow.advance(PHONE, "Zoya D'Souza-Ali"); + expect(next.status).toBe('step'); + }); + + it('the level options cover the four passage types the handler maps', async () => { + const { READING_LEVELS } = require('../../bot/shared/services/messaging/text-flow-definitions'); + expect(READING_LEVELS.map((l) => l.id)).toEqual(['0_Letters', '1_Words', '2_Sentences', '3_Paragraph']); + }); +}); + +describe('the fields are the ones the real handler actually reads', () => { + // Guards against a rename drifting the two apart: assert the handler's source + // mentions every field name the definition emits. + it('flow-response.handler.js references each synthesised field name', () => { + const src = fs.readFileSync( + path.resolve(__dirname, '../../bot/shared/handlers/flow-response.handler.js'), 'utf-8' + ); + for (const field of [ + 'Student_Full_Name', 'Language', 'Assessment_Mode', 'Select_the_reading_level', 'Scope_of_Assessment_', + ]) { + expect(src).toContain(field); + } + }); +}); + +describe('endpoint-backed definitions are wired to the real endpoints', () => { + it('student-videos drives the student-videos endpoint', async () => { + const { textFlow } = load(); + const endpoint = require('../../bot/shared/routes/student-videos-endpoint'); + const first = await textFlow.start(PHONE, 'student-videos', {}, { + _ctx: { userId: 'user-7', flowToken: 'user-7:student-videos:1', phone: PHONE }, + }); + + expect(endpoint.handleStudentVideosInit).toHaveBeenCalledWith('user-7:student-videos:1'); + expect(first.options.map((o) => o.title)).toEqual(['Grade 1']); + }); + + it('settings drives the settings endpoint with the user id', async () => { + const { textFlow } = load(); + const endpoint = require('../../bot/shared/routes/settings-endpoint'); + await textFlow.start(PHONE, 'settings', {}, { + _ctx: { userId: 'user-7', flowToken: 'user-7:settings:1', phone: PHONE }, + }); + expect(endpoint.handleSettingsInit).toHaveBeenCalledWith('user-7'); + }); + + it('settings writes both preferences through the endpoint', async () => { + const { textFlow } = load(); + const endpoint = require('../../bot/shared/routes/settings-endpoint'); + const definition = textFlow.getDefinition('settings'); + + const done = await run(textFlow, 'settings', ['English', 'OECD'], { + _ctx: { userId: 'user-7', flowToken: 'user-7:settings:1', phone: PHONE }, + }); + const outcome = await definition.onComplete(PHONE, done.answers, done.context); + + expect(endpoint.handleSettingsDataExchange).toHaveBeenCalledWith( + 'user-7', 'SETTINGS_MAIN', { language: 'en', observation_framework: 'oecd' }, 'user-7:settings:1' + ); + expect(outcome.text).toContain('Saved.'); + }); +}); diff --git a/tests/messaging/text-flow.test.js b/tests/messaging/text-flow.test.js new file mode 100644 index 0000000..60b81b1 --- /dev/null +++ b/tests/messaging/text-flow.test.js @@ -0,0 +1,219 @@ +/** + * text-flow.js — the multi-step text flow engine that stands in for a Meta + * WhatsApp Flow form on the sandbox driver. + * + * Redis is stubbed so the in-memory fallback is what runs (and so no real + * connection is opened, which would keep Jest alive). + */ + +function load({ redisWorks = false } = {}) { + jest.resetModules(); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + const store = new Map(); + jest.doMock('../../bot/shared/services/cache/railway-redis.service', () => ({ + set: jest.fn(async (k, v) => (redisWorks ? (store.set(k, v), true) : false)), + get: jest.fn(async (k) => (redisWorks ? store.get(k) || null : null)), + delete: jest.fn(async (k) => (redisWorks ? store.delete(k) : false)), + })); + const textFlow = require('../../bot/shared/services/messaging/text-flow'); + const pending = require('../../bot/shared/services/messaging/pending-options'); + textFlow._resetForTests(); + pending._resetForTests(); + return { textFlow, pending }; +} + +const PHONE = '923001234567'; + +/** grade -> subject picker, with the second step's options depending on the first. */ +function videoLikeFlow(onComplete = jest.fn()) { + return { + kind: 'student-videos', + steps: [ + { + id: 'grade', + prompt: async () => ({ header: 'Pick a class' }), + options: async () => [ + { id: 'grade_1', title: 'Grade 1' }, + { id: 'grade_2', title: 'Grade 2' }, + ], + }, + { + id: 'subject', + prompt: async (answers) => ({ header: `Subjects for ${answers.grade.title}` }), + options: async (answers) => (answers.grade.id === 'grade_1' + ? [{ id: 'subj_maths', title: 'Maths' }] + : [{ id: 'subj_urdu', title: 'Urdu' }, { id: 'subj_sci', title: 'Science' }]), + }, + ], + onComplete, + }; +} + +afterEach(() => jest.resetModules()); + +describe('registration', () => { + it('rejects a malformed definition', () => { + const { textFlow } = load(); + expect(() => textFlow.register({})).toThrow(/needs \{ kind, steps/); + expect(() => textFlow.register({ kind: 'x' })).toThrow(/needs \{ kind, steps/); + }); + + it('start() returns null for an unregistered kind so the caller can fall back', async () => { + const { textFlow } = load(); + await expect(textFlow.start(PHONE, 'nope')).resolves.toBeNull(); + }); +}); + +describe('stepping through a flow', () => { + it('renders the first step and records its menu for number-or-name matching', async () => { + const { textFlow, pending } = load(); + textFlow.register(videoLikeFlow()); + + const first = await textFlow.start(PHONE, 'student-videos'); + + expect(first.kind).toBe('menu'); + expect(first.prompt.header).toBe('Pick a class'); + expect(first.options.map((o) => o.id)).toEqual(['grade_1', 'grade_2']); + // the menu must be answerable + const menu = await pending.get(PHONE); + expect(pending.resolveSelection(menu, '2').id).toBe('grade_2'); + }); + + it('advances on a NUMBER and computes the next step from the previous answer', async () => { + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + + const next = await textFlow.advance(PHONE, '2'); // Grade 2 + + expect(next.status).toBe('step'); + expect(next.render.prompt.header).toBe('Subjects for Grade 2'); + // dynamic: Grade 2's subjects, not Grade 1's + expect(next.render.options.map((o) => o.id)).toEqual(['subj_urdu', 'subj_sci']); + }); + + it('advances on a NAME too', async () => { + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + + const next = await textFlow.advance(PHONE, 'Grade 1'); + expect(next.render.options.map((o) => o.id)).toEqual(['subj_maths']); + }); + + it('completes after the last step and returns all answers', async () => { + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + await textFlow.advance(PHONE, '2'); + + const done = await textFlow.advance(PHONE, 'Science'); + + expect(done.status).toBe('complete'); + expect(done.answers.grade.id).toBe('grade_2'); + expect(done.answers.subject.id).toBe('subj_sci'); + // flow is over — state cleared + await expect(textFlow.isActive(PHONE)).resolves.toBe(false); + }); +}); + +describe('not hijacking normal conversation', () => { + it('returns "unmatched" for prose so the caller can handle it as an ordinary message', async () => { + // A pending flow must NOT mean every message answers it. + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + + const r = await textFlow.advance(PHONE, 'actually what can you do?'); + expect(r.status).toBe('unmatched'); + // still active, so a later valid answer works + await expect(textFlow.isActive(PHONE)).resolves.toBe(true); + }); + + it('counts consecutive unmatched replies so the caller can stop trapping the user', async () => { + // Not every command starts with "/" — "add class" mid-flow looks like a + // wrong answer. The strike count is what lets the caller give up. + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + + expect((await textFlow.advance(PHONE, 'add class')).strikes).toBe(1); + expect((await textFlow.advance(PHONE, 'add class')).strikes).toBe(2); + }); + + it('resets the strike count once the user answers correctly', async () => { + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + + await textFlow.advance(PHONE, 'gibberish'); + const ok = await textFlow.advance(PHONE, '1'); + expect(ok.status).toBe('step'); + + // a single later slip must not immediately trip the give-up threshold + expect((await textFlow.advance(PHONE, 'gibberish')).strikes).toBe(1); + }); + + it('advance() returns null when no flow is active', async () => { + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await expect(textFlow.advance(PHONE, '1')).resolves.toBeNull(); + }); + + it('lets the user escape with cancel/stop/exit', async () => { + for (const word of ['cancel', 'STOP', 'exit', 'never mind']) { + const { textFlow } = load(); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + + const r = await textFlow.advance(PHONE, word); + expect(r.status).toBe('cancelled'); + await expect(textFlow.isActive(PHONE)).resolves.toBe(false); + } + }); +}); + +describe('free-text steps', () => { + it('accepts any non-empty text and rejects blank', async () => { + const { textFlow } = load(); + textFlow.register({ + kind: 'ask-topic', + steps: [{ id: 'topic', freeText: true, prompt: async () => ({ body: 'What topic?' }) }], + onComplete: jest.fn(), + }); + + const first = await textFlow.start(PHONE, 'ask-topic'); + expect(first.kind).toBe('text'); + + await expect(textFlow.advance(PHONE, ' ')).resolves.toMatchObject({ status: 'unmatched' }); + + const done = await textFlow.advance(PHONE, 'photosynthesis'); + expect(done.status).toBe('complete'); + expect(done.answers.topic.title).toBe('photosynthesis'); + }); +}); + +describe('state persistence', () => { + it('survives a restart when Redis is working', async () => { + const { textFlow } = load({ redisWorks: true }); + textFlow.register(videoLikeFlow()); + await textFlow.start(PHONE, 'student-videos'); + await textFlow.advance(PHONE, '2'); + + const state = await textFlow.getState(PHONE); + expect(state).toMatchObject({ kind: 'student-videos', stepIndex: 1 }); + expect(state.answers.grade.id).toBe('grade_2'); + }); + + it('a step with no options returns "empty" rather than crashing', async () => { + const { textFlow } = load(); + textFlow.register({ + kind: 'barren', + steps: [{ id: 'nothing', options: async () => [] }], + onComplete: jest.fn(), + }); + + const first = await textFlow.start(PHONE, 'barren'); + expect(first.kind).toBe('empty'); + }); +}); diff --git a/tests/reading/audio-without-object-storage.test.js b/tests/reading/audio-without-object-storage.test.js new file mode 100644 index 0000000..ab40514 --- /dev/null +++ b/tests/reading/audio-without-object-storage.test.js @@ -0,0 +1,137 @@ +/** + * The reading assessment must survive having no object storage. + * + * The pipeline round-trips the recording: the handler persists it and stores a + * URL, then the QUEUED analysis step downloads it again to transcribe. That + * upload used to be unconditional, so on a deployment with no bucket it threw + * "S3Client cannot be constructed — missing env: R2_ENDPOINT…" and aborted the + * whole assessment ("🚨 CRITICAL: Reading assessment audio processing failed") + * — after the teacher had already recorded the student reading. A sandbox has no + * bucket by definition, which made the entire reading feature unusable there. + * + * The fallback keeps the recording on local disk and stores a file:// URL. + * Single-machine by nature — correct for a sandbox, where the analysis runs in + * the same process; R2 remains the answer when workers live on other hosts. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const R2_KEYS = ['R2_ENDPOINT', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY']; + +function setR2(enabled) { + for (const k of R2_KEYS) { + if (enabled) process.env[k] = `test-${k}`; + else delete process.env[k]; + } +} + +describe('isR2Configured', () => { + afterEach(() => setR2(false)); + + it('is false when no credentials are set', () => { + setR2(false); + jest.resetModules(); + expect(require('../../bot/shared/storage/r2').isR2Configured()).toBe(false); + }); + + it('is true only when ALL three credentials are present', () => { + setR2(true); + jest.resetModules(); + const { isR2Configured } = require('../../bot/shared/storage/r2'); + expect(isR2Configured()).toBe(true); + + delete process.env.R2_SECRET_ACCESS_KEY; + expect(isR2Configured()).toBe(false); + }); +}); + +describe('transcription.service.downloadAudio — file:// URLs', () => { + let tmpDir; + + function loadService() { + jest.resetModules(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-reading-')); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + jest.doMock('../../bot/shared/utils/constants', () => ({ TEMP_DIR: tmpDir, SONIOX_API_KEY: 'k' })); + jest.doMock('../../bot/shared/config/supabase', () => ({ from: jest.fn() }), { virtual: true }); + const downloadFromR2 = jest.fn(); + jest.doMock('../../bot/shared/storage/r2', () => ({ + downloadFromR2, + extractKeyFromUrl: jest.fn(() => 'audio/x.ogg'), + isR2Configured: jest.fn(() => false), + })); + const service = require('../../bot/shared/services/reading/transcription.service'); + return { service, downloadFromR2 }; + } + + afterEach(() => { + jest.resetModules(); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('reads a locally-stored recording without touching R2', async () => { + const { service, downloadFromR2 } = loadService(); + const source = path.join(tmpDir, 'recording.ogg'); + fs.writeFileSync(source, 'OGGAUDIOBYTES'); + + const result = await service.downloadAudio(`file://${source}`, 'assess-1'); + + expect(fs.readFileSync(result, 'utf-8')).toBe('OGGAUDIOBYTES'); + expect(downloadFromR2).not.toHaveBeenCalled(); + }); + + it('COPIES rather than moves, so the caller deleting its temp file is safe', async () => { + // transcribeReading() deletes whatever path it gets back. Returning the + // original would destroy the only copy of the student's reading. + const { service } = loadService(); + const source = path.join(tmpDir, 'recording.ogg'); + fs.writeFileSync(source, 'BYTES'); + + const returned = await service.downloadAudio(`file://${source}`, 'assess-2'); + + expect(returned).not.toBe(source); + fs.unlinkSync(returned); // simulate the caller's cleanup + expect(fs.existsSync(source)).toBe(true); + }); + + it('fails clearly when the local recording is gone', async () => { + const { service } = loadService(); + await expect(service.downloadAudio(`file://${tmpDir}/missing.ogg`, 'assess-3')) + .rejects.toThrow(/no longer on disk/i); + }); + + it('still goes through R2 for a normal https URL', async () => { + const { service, downloadFromR2 } = loadService(); + downloadFromR2.mockResolvedValue(Buffer.from('FROM-R2')); + + const result = await service.downloadAudio('https://bucket.r2.dev/audio/x.ogg', 'assess-4'); + + expect(downloadFromR2).toHaveBeenCalledWith('audio/x.ogg'); + expect(fs.readFileSync(result, 'utf-8')).toBe('FROM-R2'); + }); +}); + +describe('the handler chooses its persistence by configuration, not by hope', () => { + // main()'s voice branch needs a live socket, DB and Soniox to run, so the + // guarantee is pinned to the source — same approach as tests/setup/bin-rumi.test.js. + const src = fs.readFileSync( + path.resolve(__dirname, '../../bot/shared/handlers/voice-message.handler.js'), 'utf-8' + ); + + it('only uploads to R2 when R2 is configured', () => { + expect(src).toMatch(/if \(isR2Configured\(\)\) \{[\s\S]{0,200}uploadAudio\(/); + }); + + it('falls back to a file:// URL otherwise', () => { + expect(src).toMatch(/audioUrl = `file:\/\/\$\{audioPath\}`/); + }); + + it('does NOT delete the recording it still needs to read back', () => { + // The unlink must live inside the R2 branch only. + const fallback = src.slice(src.indexOf('audioUrl = `file://')); + const nextBlockEnd = fallback.indexOf('typingController.stop()'); + expect(fallback.slice(0, nextBlockEnd)).not.toMatch(/unlinkSync/); + }); +}); diff --git a/tests/setup/_audit-helpers/require-graph.js b/tests/setup/_audit-helpers/require-graph.js index 3909ab9..29703d3 100644 --- a/tests/setup/_audit-helpers/require-graph.js +++ b/tests/setup/_audit-helpers/require-graph.js @@ -117,7 +117,11 @@ function resolveLocal(fromDir, spec) { continue; } if (!stat.isFile()) continue; - if (path.basename(c) === 'package.json') { + // Only treat package.json as a package *manifest* (follow its `main`) when + // we reached it by looking inside a directory. A spec that names the file + // outright — `require('../package.json')`, how a CLI reads its own version — + // resolves to that file's contents, exactly as Node does. + if (path.basename(c) === 'package.json' && !spec.endsWith('package.json')) { try { const pj = JSON.parse(fs.readFileSync(c, 'utf8')); const main = pj.main || 'index.js'; diff --git a/tests/setup/bin-rumi.test.js b/tests/setup/bin-rumi.test.js new file mode 100644 index 0000000..679f38e --- /dev/null +++ b/tests/setup/bin-rumi.test.js @@ -0,0 +1,198 @@ +/** + * bin/rumi.js — the CLI dispatcher. Command handlers lazily require heavy + * modules internally, so routing is tested by swapping COMMANDS entries for + * spies (the module exports COMMANDS for exactly this) rather than mocking + * the dynamically-computed require paths. + */ + +const path = require('path'); + +function loadCli() { + jest.resetModules(); + return require('../../bin/rumi.js'); +} + +const originalArgv = process.argv; +afterEach(() => { + process.argv = originalArgv; + process.exitCode = undefined; + jest.restoreAllMocks(); +}); + +describe('rumi CLI dispatcher', () => { + it('exposes exactly the documented commands', () => { + const { COMMANDS } = loadCli(); + expect(Object.keys(COMMANDS).sort()).toEqual(['doctor', 'graduate', 'pair', 'setup', 'start', 'status']); + }); + + it('gives every command a one-line summary, since the help screen is built from them', () => { + const { COMMANDS } = loadCli(); + for (const [name, command] of Object.entries(COMMANDS)) { + expect(typeof command.summary).toBe('string'); + expect(command.summary.length).toBeGreaterThan(10); + expect(typeof command.run).toBe('function'); + expect(command.summary).not.toContain(name.toUpperCase()); + } + }); + + it('routes "rumi setup" to COMMANDS.setup', async () => { + const cli = loadCli(); + const spy = jest.fn().mockResolvedValue(undefined); + cli.COMMANDS.setup.run = spy; + process.argv = ['node', 'bin/rumi.js', 'setup']; + + await cli.main(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('routes "rumi graduate" to COMMANDS.graduate', async () => { + const cli = loadCli(); + const spy = jest.fn().mockResolvedValue(undefined); + cli.COMMANDS.graduate.run = spy; + process.argv = ['node', 'bin/rumi.js', 'graduate']; + + await cli.main(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('prints usage and exits non-zero with no command', async () => { + const cli = loadCli(); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + process.argv = ['node', 'bin/rumi.js']; + + await cli.main(); + + const printed = logSpy.mock.calls.join('\n'); + expect(printed).toMatch(/Usage/); + expect(printed).toMatch(/rumi /); + expect(process.exitCode).toBe(1); + }); + + it('points a newcomer at `rumi setup` from the help screen', async () => { + const cli = loadCli(); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + process.argv = ['node', 'bin/rumi.js', '--help']; + + await cli.main(); + + expect(logSpy.mock.calls.join('\n')).toMatch(/New here\? Run `rumi setup`/); + }); + + it('reports its version', async () => { + const cli = loadCli(); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + process.argv = ['node', 'bin/rumi.js', '--version']; + + await cli.main(); + + expect(logSpy.mock.calls.join('\n')).toMatch(/^rumi \d+\.\d+/m); + expect(process.exitCode).toBeUndefined(); + }); + + it('prints usage and exits zero for --help', async () => { + const cli = loadCli(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + process.argv = ['node', 'bin/rumi.js', '--help']; + + await cli.main(); + expect(process.exitCode).toBe(0); + }); + + it('reports an unknown command clearly and exits non-zero', async () => { + const cli = loadCli(); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + process.argv = ['node', 'bin/rumi.js', 'bogus']; + + await cli.main(); + + expect(logSpy.mock.calls.join('\n')).toMatch(/Unknown command: "bogus"/); + expect(process.exitCode).toBe(1); + }); +}); + +describe('rumi start', () => { + it('runs the bot from the repo root, whatever directory rumi was called from', () => { + // `cd bot && npm start` ran the bot with bot/ as its working directory, + // where a relative .env and a relative CHANNEL_STATE_DIR both resolved + // somewhere else: the bot aborted on "Missing REQUIRED env var(s)" for a + // fully-configured deployment, and once past that it paired a *second* + // WhatsApp device and re-synced endlessly. This command exists to make the + // launch directory irrelevant. + const src = require('fs').readFileSync(path.resolve(__dirname, '../../bin/rumi.js'), 'utf-8'); + expect(src).toMatch(/cwd: REPO_ROOT/); + expect(src).toMatch(/whatsapp-bot\.js/); + }); + + it('forwards signals to the bot, so stopping rumi stops the bot', () => { + // Killing the launcher alone orphaned the bot, which kept the WhatsApp + // session lock. The next `rumi start` then refused to attach — correctly, + // but blaming a pid with no visible owner. + const src = require('fs').readFileSync(path.resolve(__dirname, '../../bin/rumi.js'), 'utf-8'); + expect(src).toMatch(/process\.on\('SIGINT'/); + expect(src).toMatch(/process\.on\('SIGTERM'/); + expect(src).toMatch(/child\.kill\(signal\)/); + }); + + it('does not pass RUMI_CLI down to the bot, which wants its structured logging', () => { + const src = require('fs').readFileSync(path.resolve(__dirname, '../../bin/rumi.js'), 'utf-8'); + expect(src).toMatch(/filter\(\(\[k\]\) => k !== 'RUMI_CLI'\)/); + }); +}); + +describe('rumi doctor — dotenv resolution (regression)', () => { + // Real-world discovery from a live `rumi doctor` run: bin/rumi.js sits at + // the repo root, but dotenv is only a dependency of bot/package.json (not + // the root package.json). A bare require('dotenv') from bin/rumi.js threw + // MODULE_NOT_FOUND, silently swallowed by a try/catch — .env never loaded, + // and every required var reported "missing" even with a fully-configured + // .env. The fix resolves dotenv via bot/node_modules explicitly, the same + // place every bot/scripts/setup/*.js file already loads it from. + it('resolves dotenv via bot/node_modules, not by a bare require', () => { + // The bug: bin/rumi.js sits at the repo root, but dotenv is a dependency of + // bot/package.json only — so a bare require('dotenv') there threw + // MODULE_NOT_FOUND, was swallowed by a try/catch, and .env silently never + // loaded (every required var reported "missing" on a configured deployment). + // + // Asserted against the SOURCE rather than by calling require('dotenv') here: + // tests/jest.config.js maps '^dotenv$' to a mock, so inside Jest a bare + // require always succeeds and could never reproduce the real failure. + const src = require('fs').readFileSync(path.resolve(__dirname, '../../bin/rumi.js'), 'utf-8'); + expect(src).toMatch(/BOT_DIR/); + expect(src).toMatch(/require\(path\.join\(BOT_DIR, 'node_modules', 'dotenv'\)\)/); + + // And loaded from the REPO's .env, not the working directory's — run from + // bot/, a bare config() loaded nothing and every command reported a + // configured deployment as "not configured". + expect(src).toMatch(/config\(\{ path: path\.join\(REPO_ROOT, '\.env'\)/); + + // Comment lines are stripped first — the file explains the bug in prose that + // legitimately contains the bad form. + const code = src.split('\n').filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*')).join('\n'); + expect(code).not.toMatch(/require\('dotenv'\)/); + }); + + it('dotenv is declared as a bot dependency — what makes that path resolvable', () => { + // The contract, checkable without an install: bin/rumi.js resolves dotenv + // out of bot/node_modules, which only works because bot/package.json owns + // it. CI runs the root suite BEFORE `cd bot && npm ci`, so asserting the + // installed file exists would fail there for reasons unrelated to the bug + // this protects. + const botPkg = JSON.parse(require('fs').readFileSync( + path.resolve(__dirname, '../../bot/package.json'), 'utf-8', + )); + expect(botPkg.dependencies.dotenv).toBeDefined(); + + const rootPkg = JSON.parse(require('fs').readFileSync( + path.resolve(__dirname, '../../package.json'), 'utf-8', + )); + // And the root does NOT have it — the whole reason the explicit path exists. + expect((rootPkg.dependencies || {}).dotenv).toBeUndefined(); + }); + + const botDepsInstalled = require('fs').existsSync(path.resolve(__dirname, '../../bot/node_modules')); + const whenInstalled = botDepsInstalled ? it : it.skip; + whenInstalled('bot/node_modules/dotenv is really there once bot deps are installed', () => { + const pkg = path.resolve(__dirname, '../../bot/node_modules/dotenv/package.json'); + expect(require('fs').existsSync(pkg)).toBe(true); + }); +}); diff --git a/tests/setup/cli-console.test.js b/tests/setup/cli-console.test.js new file mode 100644 index 0000000..082e1d7 --- /dev/null +++ b/tests/setup/cli-console.test.js @@ -0,0 +1,205 @@ +/** + * The `rumi` commands must keep a real console. + * + * `bot/shared/utils/structured-logger.js` replaces `console.*` with JSON logging + * the moment it is imported — correct for the bot server, ruinous for a terminal + * conversation. And it is not opt-in: the WhatsApp connection module reaches it + * through `logToFile`, so any command that touches WhatsApp inherits it. + * + * The sharpest consequence is the QR code. `qrcode-terminal` renders it with + * `console.log`, so with the override in place the code arrives as one JSON + * record with `\n` escapes in it — present, plausible-looking in a log, and + * completely unscannable. Pairing then depends on how the log formatter happens + * to be configured, which is not a thing pairing should depend on. + * + * So: every CLI entry point sets `RUMI_CLI=1` before its first require, and the + * logger honours it. Both halves are checked here, because either one alone is + * silently useless. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '../..'); + +const ENTRY_POINTS = [ + 'bin/rumi.js', + 'bot/scripts/setup/interactive-setup.js', + 'bot/scripts/setup/baileys-pair.js', + 'bot/scripts/setup/status.js', + 'bot/scripts/setup/graduate.js', +]; + +/** Loads structured-logger in isolation and reports whether it took the console. */ +function consoleAfterLoading({ asCli }) { + const saved = { + log: console.log, error: console.error, warn: console.warn, info: console.info, debug: console.debug, + }; + const previousFlag = process.env.RUMI_CLI; + jest.resetModules(); + if (asCli) process.env.RUMI_CLI = '1'; + else delete process.env.RUMI_CLI; + + try { + require('../../bot/shared/utils/structured-logger'); + return { hijacked: console.log !== saved.log }; + } finally { + Object.assign(console, saved); + if (previousFlag === undefined) delete process.env.RUMI_CLI; + else process.env.RUMI_CLI = previousFlag; + jest.resetModules(); + } +} + +describe('every CLI entry point claims a human console', () => { + it.each(ENTRY_POINTS)('%s sets RUMI_CLI before its first require', (file) => { + const source = fs.readFileSync(path.join(ROOT, file), 'utf-8'); + + const flagAt = source.indexOf("process.env.RUMI_CLI = '1'"); + expect(flagAt).toBeGreaterThan(-1); + + // Order is the whole point: the override runs at import time, so a flag set + // after the first require is a flag set too late. + const firstRequire = source.search(/^\s*(const|let|var)\s.*require\(/m); + expect(firstRequire).toBeGreaterThan(flagAt); + }); +}); + +describe('structured-logger honours it', () => { + it('takes over console.* by default — the bot server still gets JSON', () => { + expect(consoleAfterLoading({ asCli: false }).hijacked).toBe(true); + }); + + it('leaves console.* alone for a CLI command', () => { + expect(consoleAfterLoading({ asCli: true }).hijacked).toBe(false); + }); +}); + +describe('logToFile', () => { + const withCliFlag = (fn) => { + const previous = process.env.RUMI_CLI; + process.env.RUMI_CLI = '1'; + try { return fn(); } finally { + if (previous === undefined) delete process.env.RUMI_CLI; + else process.env.RUMI_CLI = previous; + } + }; + + it('does not echo internal diagnostics onto a CLI\'s screen', () => { + jest.resetModules(); + const { logToFile } = require('../../bot/shared/utils/logger'); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + withCliFlag(() => logToFile('Baileys: connection closed for shutdown', { flushMs: 500 })); + + expect(logSpy).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it('still writes the line to the log file — nothing is lost, only relocated', () => { + jest.resetModules(); + const { logToFile, LOGS_DIR } = require('../../bot/shared/utils/logger'); + const logFile = path.join(LOGS_DIR, `bot-${new Date().toISOString().split('T')[0]}.log`); + const before = fs.existsSync(logFile) ? fs.statSync(logFile).size : 0; + + withCliFlag(() => logToFile('a marker line written during the cli-console test')); + + expect(fs.statSync(logFile).size).toBeGreaterThan(before); + }); + + it('still echoes to console for the bot server', () => { + jest.resetModules(); + const previous = process.env.RUMI_CLI; + delete process.env.RUMI_CLI; + const { logToFile } = require('../../bot/shared/utils/logger'); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + try { + logToFile('a server-side line'); + expect(logSpy).toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + if (previous !== undefined) process.env.RUMI_CLI = previous; + } + }); +}); + +describe('the QR code, which is what all of this is protecting', () => { + // Reading a third-party library's source needs bot/node_modules, and CI runs + // the root suite BEFORE `cd bot && npm ci` (see tests/setup/worker-boot.test.js + // for the same pattern). Skipped, not silently passed, when absent. + const QR_MAIN = path.join(ROOT, 'bot/node_modules/qrcode-terminal/lib/main.js'); + const whenInstalled = fs.existsSync(QR_MAIN) ? it : it.skip; + + whenInstalled('is rendered through console.log — hence the whole arrangement above', () => { + // Asserted against the library so this test's premise cannot go stale + // silently: if qrcode-terminal ever switched to process.stdout.write, the + // console override would stop mattering for pairing and this file could go. + expect(fs.readFileSync(QR_MAIN, 'utf-8')).toMatch(/console\.log\(output\)/); + }); + + it('survives a multi-line write with its newlines intact under RUMI_CLI', () => { + const previous = process.env.RUMI_CLI; + process.env.RUMI_CLI = '1'; + jest.resetModules(); + const saved = console.log; + const written = []; + try { + require('../../bot/shared/utils/structured-logger'); + console.log = (...args) => written.push(args.join(' ')); + console.log('▄▄▄▄▄\n█ ▄ █\n▄▄▄▄▄'); + // A scannable code needs real line breaks. JSON-encoded it would arrive + // as one line containing a literal backslash-n. + expect(written[0].split('\n')).toHaveLength(3); + expect(written[0]).not.toContain('\\n'); + } finally { + console.log = saved; + if (previous === undefined) delete process.env.RUMI_CLI; + else process.env.RUMI_CLI = previous; + jest.resetModules(); + } + }); +}); + +describe('Baileys own logger is silenced for an interactive command', () => { + // Its default logger writes the whole handshake at info level as raw JSON. + // In a live `rumi pair` that put ~15 lines of it immediately above and below + // the QR code — the one thing on screen the user actually had to use. + const connectionSource = fs.readFileSync( + path.join(ROOT, 'bot/shared/services/messaging/baileys-connection.js'), 'utf-8', + ); + + it('sets the logger key only for a CLI — never as an explicit undefined', () => { + // `logger: isCli ? quiet : undefined` reads as a no-op for the server but is + // not: Baileys merges config over its defaults, so an explicit undefined + // overwrites its default logger and the next `logger.child()` call throws. + // That took the bot's whole WhatsApp connection down while every other + // service reported healthy — asserted here on the shape that caused it. + // Comments stripped first: the file explains this bug in prose that + // legitimately contains the bad form. + const code = connectionSource.split('\n') + .filter((line) => !line.trim().startsWith('//') && !line.trim().startsWith('*')) + .join('\n'); + expect(code).not.toMatch(/logger:[^\n]*undefined/); + expect(code).toMatch(/if \(process\.env\.RUMI_CLI === '1'\) socketConfig\.logger = quietBaileysLogger\(\)/); + }); + + it('the quiet logger has the shape Baileys expects, and swallows everything', () => { + const { quietBaileysLogger } = require('../../bot/shared/services/messaging/baileys-connection'); + const logger = quietBaileysLogger(); + + // Baileys calls .child() and every level on whatever it is given; a stub + // missing one of them takes pairing down with it. + for (const method of ['fatal', 'error', 'warn', 'info', 'debug', 'trace']) { + expect(typeof logger[method]).toBe('function'); + expect(() => logger[method]({ a: 1 }, 'msg')).not.toThrow(); + } + expect(typeof logger.child).toBe('function'); + expect(typeof logger.child({ class: 'baileys' }).info).toBe('function'); + }); + + it('does not print the Axiom "logging disabled" warning to a CLI', () => { + const source = fs.readFileSync(path.join(ROOT, 'bot/shared/utils/structured-logger.js'), 'utf-8'); + expect(source).toMatch(/else if \(process\.env\.RUMI_CLI !== '1'\)/); + }); +}); diff --git a/tests/setup/db-setup.test.js b/tests/setup/db-setup.test.js new file mode 100644 index 0000000..fcd8238 --- /dev/null +++ b/tests/setup/db-setup.test.js @@ -0,0 +1,103 @@ +/** + * db-setup.js — telling apart the three states a Supabase project can be in. + * + * This distinction is the whole reason the module exists. "No tables yet" and + * "no tables and no way to create them" look identical from the outside but need + * opposite instructions, and conflating them is what produces the classic + * self-serve dead end: a 404 from an RPC nobody mentioned, on a project the user + * just created correctly. + */ + +const dbSetup = require('../../bot/scripts/setup/db-setup'); + +const ENV = { SUPABASE_URL: 'https://abcdefgh.supabase.co', SUPABASE_SERVICE_ROLE_KEY: 'service-key' }; + +/** A fetch stand-in that answers by URL. */ +function fakeFetch(routes) { + return jest.fn(async (url) => { + for (const [pattern, response] of Object.entries(routes)) { + if (url.includes(pattern)) return response; + } + throw new Error(`unexpected request: ${url}`); + }); +} + +const respond = (status, body = '') => ({ + status, ok: status >= 200 && status < 300, text: async () => body, +}); + +describe('inspectDatabase', () => { + it('reports ready when the schema is already applied', async () => { + const result = await dbSetup.inspectDatabase(ENV, fakeFetch({ '/rest/v1/users': respond(200, '[]') })); + expect(result.state).toBe('ready'); + }); + + it('reports needs-schema when the tables are missing but the SQL helper is there', async () => { + const result = await dbSetup.inspectDatabase(ENV, fakeFetch({ + '/rest/v1/users': respond(404, 'PGRST205'), + '/rpc/exec_sql': respond(200), + })); + expect(result.state).toBe('needs-schema'); + }); + + it('reports needs-helper when the helper is missing too — the manual step', async () => { + const result = await dbSetup.inspectDatabase(ENV, fakeFetch({ + '/rest/v1/users': respond(404, 'PGRST205'), + '/rpc/exec_sql': respond(404, 'Could not find the function public.exec_sql'), + })); + expect(result.state).toBe('needs-helper'); + }); + + it('reports unreachable — not "no tables" — when the key is rejected', async () => { + // A wrong key answers 401 for every table. Calling that "no schema yet" + // would send the user to create tables they may already have. + const result = await dbSetup.inspectDatabase(ENV, fakeFetch({ '/rest/v1/users': respond(401) })); + expect(result.state).toBe('unreachable'); + expect(result.detail).toMatch(/rejected/); + }); + + it('reports unreachable with the network error when the host does not answer', async () => { + const failing = jest.fn(async () => { throw new Error('getaddrinfo ENOTFOUND'); }); + const result = await dbSetup.inspectDatabase(ENV, failing); + expect(result.state).toBe('unreachable'); + expect(result.detail).toMatch(/ENOTFOUND/); + }); +}); + +describe('hasExecSql', () => { + it('probes with a harmless statement — existing and working are different claims', async () => { + const fetchImpl = fakeFetch({ '/rpc/exec_sql': respond(200) }); + const result = await dbSetup.hasExecSql(ENV, fetchImpl); + + expect(result.present).toBe(true); + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ query: 'select 1' }); + }); + + it('reports absent with the status when it is not callable', async () => { + const result = await dbSetup.hasExecSql(ENV, fakeFetch({ '/rpc/exec_sql': respond(404, 'not found') })); + expect(result).toMatchObject({ present: false }); + expect(result.detail).toMatch(/404/); + }); +}); + +describe('sqlEditorUrl', () => { + it('lands on the right page of the right project', () => { + // Handing over a precise link is the difference between a two-minute step + // and hunting through a dashboard. + expect(dbSetup.sqlEditorUrl('https://abcdefgh.supabase.co')) + .toBe('https://supabase.com/dashboard/project/abcdefgh/sql/new'); + }); + + it('returns null for a self-hosted project, where no such page exists', () => { + expect(dbSetup.sqlEditorUrl('http://localhost:54321')).toBeNull(); + expect(dbSetup.sqlEditorUrl('')).toBeNull(); + }); +}); + +describe('the one-time helper definition', () => { + it('is the function every schema and migration script here runs SQL through', () => { + const sql = dbSetup.EXEC_SQL_DEFINITION.join(' '); + expect(sql).toMatch(/create or replace function exec_sql\(query text\)/i); + expect(sql).toMatch(/execute query/i); + }); +}); diff --git a/tests/setup/doctor.test.js b/tests/setup/doctor.test.js index 4c51f1b..0e6eea6 100644 --- a/tests/setup/doctor.test.js +++ b/tests/setup/doctor.test.js @@ -9,7 +9,7 @@ */ const { - analyzeEnv, analyzeFlows, runDoctor, formatReport, keySource, REQUIRED_VARS, + analyzeEnv, analyzeFlows, runDoctor, formatReport, keySource, REQUIRED_VARS, CHANNEL_REQUIRED_VARS, requiredVarsFor, } = require('../../bot/scripts/setup/doctor'); const FULL_ENV = { @@ -34,7 +34,22 @@ describe('analyzeEnv', () => { it('reports all required present when the full env is set', () => { const a = analyzeEnv(FULL_ENV); expect(a.missingRequired).toEqual([]); - expect(a.requiredPresent.sort()).toEqual([...REQUIRED_VARS].sort()); + // FULL_ENV sets all 4 Meta vars with no explicit CHANNEL_DRIVER, so the + // channel is inferred as `meta` and the full 8-var list applies. + expect(a.requiredPresent.sort()).toEqual(requiredVarsFor(FULL_ENV).sort()); + expect(a.channel).toBe('meta'); + }); + + it('resolves to the sandbox (baileys) channel and requires only the 4 core vars when no Meta vars are set', () => { + const env = { + SUPABASE_URL: 'https://x.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'k', + OPENROUTER_API_KEY: 'k', + REDIS_URL: 'redis://localhost:6379', + }; + const a = analyzeEnv(env); + expect(a.channel).toBe('baileys'); + expect(a.missingRequired).toEqual([]); }); it('flags a missing required var', () => { @@ -108,6 +123,78 @@ describe('runDoctor', () => { const on = await runDoctor({ env: { ...FULL_ENV, MISTRAL_API_KEY: 'k' }, probes: allPassProbes }); expect(on.featureResults.find((f) => f.name.includes('Exam')).status).toBe('on'); }); + + it('sandbox (baileys) deployments are ok=true without any Meta credentials, and the WhatsApp probe is skipped', async () => { + const env = { + SUPABASE_URL: 'https://x.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'k', + OPENROUTER_API_KEY: 'k', + REDIS_URL: 'redis://localhost:6379', + CHANNEL_DRIVER: 'baileys', + }; + let whatsappProbed = false; + const probes = { ...allPassProbes, whatsapp: async () => { whatsappProbed = true; return { ok: true, detail: 'HTTP 200' }; } }; + const r = await runDoctor({ env, probes }); + expect(r.channel).toBe('baileys'); + expect(r.ok).toBe(true); + expect(whatsappProbed).toBe(false); + expect(r.probeResults.find((p) => p.name.includes('WhatsApp')).status).toBe('skip'); + }); +}); + +describe('formatReport — channel driver line', () => { + it('shows the resolved channel and its tier', async () => { + const r = await runDoctor({ env: FULL_ENV, probes: allPassProbes }); + expect(formatReport(r)).toMatch(/Channel driver: meta \(production\)/); + }); + + it('does not print a channel line when the result has no channel field (hand-built result objects)', () => { + const result = { ok: true, missingRequired: [], probeResults: [], featureResults: [], flowResults: [] }; + expect(formatReport(result)).not.toMatch(/Channel driver:/); + }); + + it('notes that a green result does not by itself confirm Baileys messaging works end to end', async () => { + const env = { + SUPABASE_URL: 'https://x.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'k', + OPENROUTER_API_KEY: 'k', + REDIS_URL: 'redis://localhost:6379', + CHANNEL_DRIVER: 'baileys', + }; + const r = await runDoctor({ env, probes: allPassProbes }); + const report = formatReport(r); + expect(report).toMatch(/Channel driver: baileys \(sandbox\)/); + expect(report).toMatch(/does NOT confirm messaging works end to end/i); + expect(report).toMatch(/rumi pair/); + }); + + it('does not print the Baileys-specific note for the meta channel', async () => { + const r = await runDoctor({ env: FULL_ENV, probes: allPassProbes }); + expect(formatReport(r)).not.toMatch(/rumi pair/); + }); + + it('warns when CHANNEL_DRIVER is set to an unrecognized value, naming the typo and the fallback', async () => { + const env = { + SUPABASE_URL: 'https://x.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'k', + OPENROUTER_API_KEY: 'k', + REDIS_URL: 'redis://localhost:6379', + CHANNEL_DRIVER: 'mta', + }; + const a = analyzeEnv(env); + expect(a.channelDriverTypo).toBe('mta'); + expect(a.channel).toBe('baileys'); + + const r = await runDoctor({ env, probes: allPassProbes }); + const report = formatReport(r); + expect(report).toMatch(/CHANNEL_DRIVER="mta" is not a recognized driver/); + expect(report).toMatch(/falling back to baileys/); + }); + + it('does not warn about a typo when CHANNEL_DRIVER is unset (inference is not a typo)', () => { + const a = analyzeEnv({ ...FULL_ENV }); + expect(a.channelDriverTypo).toBeNull(); + }); }); describe('key sourcing ("get it here" guidance)', () => { @@ -116,8 +203,9 @@ describe('key sourcing ("get it here" guidance)', () => { expect(keySource('NOT_A_REAL_VAR')).toBe(''); }); - it('every REQUIRED var has a documented "get it here" source', () => { - const undocumented = REQUIRED_VARS.filter((v) => !keySource(v)); + it('every REQUIRED var (core + every channel driver) has a documented "get it here" source', () => { + const allVars = [...REQUIRED_VARS, ...Object.values(CHANNEL_REQUIRED_VARS).flat()]; + const undocumented = allVars.filter((v) => !keySource(v)); expect(undocumented).toEqual([]); }); @@ -166,3 +254,76 @@ describe('flow registration state (analyzeFlows + doctor reporting)', () => { expect(formatReport(r)).toMatch(/npm run setup:flows/); }); }); + +describe('the real OpenRouter probe — a valid key is not the same as a usable one', () => { + // Live finding: doctor reported "✅ OpenRouter (LLM) — HTTP 200" and "All + // required services are configured and reachable" on an account with zero + // credits, while every substantial LLM call failed with HTTP 402. A green tick + // there sends the operator hunting for a bug in the bot. + const { defaultProbes } = require('../../bot/scripts/setup/doctor'); + const ENV = { OPENROUTER_API_KEY: 'test-key' }; + + const mockFetch = (routes) => jest.fn(async (url) => { + for (const [fragment, response] of Object.entries(routes)) { + if (String(url).includes(fragment)) return response; + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const okJson = (body) => ({ ok: true, status: 200, json: async () => body }); + + let realFetch; + beforeEach(() => { realFetch = global.fetch; }); + afterEach(() => { global.fetch = realFetch; }); + + it('is exported so the credit behaviour can be tested at all', () => { + expect(typeof defaultProbes.openrouter).toBe('function'); + }); + + it('fails a key whose account has no credits, and says where to fix it', async () => { + global.fetch = mockFetch({ + '/v1/key': okJson({}), + '/v1/credits': okJson({ data: { total_credits: 0, total_usage: 0.088 } }), + }); + const result = await defaultProbes.openrouter(ENV); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/no credits/i); + expect(result.detail).toMatch(/openrouter\.ai\/settings\/credits/); + }); + + it('fails a key whose granted credits are spent', async () => { + global.fetch = mockFetch({ + '/v1/key': okJson({}), + '/v1/credits': okJson({ data: { total_credits: 5, total_usage: 5 } }), + }); + const result = await defaultProbes.openrouter(ENV); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/exhausted/i); + }); + + it('passes a funded key and reports what is left', async () => { + global.fetch = mockFetch({ + '/v1/key': okJson({}), + '/v1/credits': okJson({ data: { total_credits: 10, total_usage: 2.5 } }), + }); + const result = await defaultProbes.openrouter(ENV); + expect(result.ok).toBe(true); + expect(result.detail).toMatch(/\$7\.50 credit remaining/); + }); + + it('fails an invalid key without even asking about credits', async () => { + global.fetch = mockFetch({ '/v1/key': { ok: false, status: 401 } }); + const result = await defaultProbes.openrouter(ENV); + expect(result).toEqual({ ok: false, detail: 'HTTP 401' }); + }); + + it('still passes a working key when the credits endpoint is unavailable', async () => { + // Only a definite answer may downgrade the result; an unreadable credits + // endpoint must not turn a perfectly good key red. + global.fetch = mockFetch({ + '/v1/key': okJson({}), + '/v1/credits': { ok: false, status: 500 }, + }); + await expect(defaultProbes.openrouter(ENV)).resolves.toEqual({ ok: true, detail: 'HTTP 200' }); + }); +}); diff --git a/tests/setup/env-file.test.js b/tests/setup/env-file.test.js new file mode 100644 index 0000000..6c044b9 --- /dev/null +++ b/tests/setup/env-file.test.js @@ -0,0 +1,101 @@ +/** + * env-file.js — the .env patcher `rumi setup` and `rumi graduate` share. + * Must never regenerate an existing file, only patch the keys it's given. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { readEnvFile, writeEnvVars } = require('../../bot/scripts/setup/env-file'); + +function tempEnvPath() { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-env-test-')), '.env'); +} + +describe('readEnvFile', () => { + it('returns {} for a file that does not exist', () => { + expect(readEnvFile(path.join(os.tmpdir(), 'definitely-not-there.env'))).toEqual({}); + }); + + it('parses KEY=VALUE lines and skips comments/blank lines', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, '# a comment\n\nFOO=bar\nBAZ=qux\n'); + expect(readEnvFile(p)).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); +}); + +describe('writeEnvVars', () => { + it('creates the file from a template when it does not exist yet', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-env-test-')); + const templatePath = path.join(dir, '.env.template'); + const envPath = path.join(dir, '.env'); + fs.writeFileSync(templatePath, '# header comment\nEXISTING=CHANGEME\n'); + + writeEnvVars(envPath, { NEW_KEY: 'value1' }, { fromTemplatePath: templatePath }); + + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('# header comment'); + expect(content).toContain('EXISTING=CHANGEME'); + expect(content).toContain('NEW_KEY=value1'); + }); + + it('replaces an existing key IN PLACE, preserving every other line verbatim', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, '# keep me\nSUPABASE_URL=CHANGEME\nOTHER=untouched\n\n# trailing comment\n'); + + writeEnvVars(p, { SUPABASE_URL: 'https://real.supabase.co' }); + + const lines = fs.readFileSync(p, 'utf-8').split('\n'); + expect(lines[0]).toBe('# keep me'); + expect(lines[1]).toBe('SUPABASE_URL=https://real.supabase.co'); + expect(lines[2]).toBe('OTHER=untouched'); + expect(lines).toContain('# trailing comment'); + }); + + it('appends a key that has no existing line', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, 'FOO=bar\n'); + writeEnvVars(p, { CHANNEL_DRIVER: 'meta' }); + expect(readEnvFile(p)).toEqual({ FOO: 'bar', CHANNEL_DRIVER: 'meta' }); + }); + + it('patches multiple keys in one call without disturbing unrelated ones', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, 'A=1\nB=2\nC=3\n'); + writeEnvVars(p, { A: '10', C: '30' }); + expect(readEnvFile(p)).toEqual({ A: '10', B: '2', C: '30' }); + }); + + it('is idempotent — writing the same values twice produces the same file', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, 'A=1\n'); + writeEnvVars(p, { B: '2' }); + const first = fs.readFileSync(p, 'utf-8'); + writeEnvVars(p, { B: '2' }); + const second = fs.readFileSync(p, 'utf-8'); + expect(second).toBe(first); + }); + + it('collapses a hand-edited DUPLICATE key to a single, correctly-patched line — never leaves a stale duplicate as the effective (dotenv last-wins) value', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, 'CHANNEL_DRIVER=baileys\nFOO=bar\nCHANNEL_DRIVER=meta\n'); + + writeEnvVars(p, { CHANNEL_DRIVER: 'PATCHED' }); + + const lines = fs.readFileSync(p, 'utf-8').trim().split('\n'); + const channelDriverLines = lines.filter((l) => l.startsWith('CHANNEL_DRIVER=')); + expect(channelDriverLines).toEqual(['CHANNEL_DRIVER=PATCHED']); + expect(readEnvFile(p).CHANNEL_DRIVER).toBe('PATCHED'); + }); + + it('normalizes CRLF line endings to LF on write (no mixed-EOL file)', () => { + const p = tempEnvPath(); + fs.writeFileSync(p, 'FOO=bar\r\nBAZ=CHANGEME\r\n'); + + writeEnvVars(p, { BAZ: 'real-value' }); + + const raw = fs.readFileSync(p, 'utf-8'); + expect(raw).not.toContain('\r'); + expect(readEnvFile(p)).toEqual({ FOO: 'bar', BAZ: 'real-value' }); + }); +}); diff --git a/tests/setup/env-template-completeness.test.js b/tests/setup/env-template-completeness.test.js index d71579d..c1fc1d9 100644 --- a/tests/setup/env-template-completeness.test.js +++ b/tests/setup/env-template-completeness.test.js @@ -70,6 +70,18 @@ const ALLOWED_MISSING = new Set([ 'USER', 'SHELL', 'TERM', + // Set by the `rumi` commands themselves, before their first require, to stop + // structured-logger.js taking over console.* — see tests/setup/cli-console.test.js. + // Never configured for a deployment; the bot server must never see it set. + 'RUMI_CLI', + + // Terminal colour conventions the CLI honours (bot/scripts/setup/ui.js). + // Set by the terminal or by the person running a command, never configured + // for a deployment — putting them in .env.template would invite someone to + // pin NO_COLOR for their whole install. + 'NO_COLOR', + 'FORCE_COLOR', + 'COLORTERM', 'LANG', 'TMPDIR', 'TZ', diff --git a/tests/setup/fake-io.js b/tests/setup/fake-io.js new file mode 100644 index 0000000..59a6e58 --- /dev/null +++ b/tests/setup/fake-io.js @@ -0,0 +1,77 @@ +/** + * A stand-in for prompt.js's `createIo()`, so wizard steps can be tested as + * behaviour instead of as scripted keystrokes. + * + * Answers are queued per method, which matters: a step's questions are not + * interchangeable, and a single flat queue made every test break whenever a + * step gained a confirmation. Each method also records what it was asked, so a + * test can assert on the *wording* — that is where the "never ask for + * SUPABASE_URL by name" guarantee actually lives. + * + * Not a `*.test.js` file, so Jest treats it as a helper rather than a suite. + */ + +/** + * @param {{ask?: string[], confirm?: boolean[], select?: string[]}} answers + * `ask` entries of `undefined`/'' mean "the user pressed Enter", which the + * real io resolves to the field's fallback — mirrored here. + */ +function fakeIo(answers = {}) { + const queues = { + ask: [...(answers.ask || [])], + confirm: [...(answers.confirm || [])], + select: [...(answers.select || [])], + }; + const asked = { ask: [], confirm: [], select: [], pressEnter: 0 }; + const validationFailures = []; + + return { + asked, + validationFailures, + + async ask(label, opts = {}) { + asked.ask.push({ label, ...opts }); + const raw = queues.ask.length ? queues.ask.shift() : ''; + const answer = (raw === undefined || raw === '') ? (opts.fallback || '') : raw; + if (opts.validate) { + const verdict = opts.validate(answer); + if (!verdict.ok) { + // The real io re-asks; a test that queued a bad value wants to see + // that it was rejected, not spin forever. + validationFailures.push({ label, answer, reason: verdict.reason }); + return answer; + } + return verdict.value === undefined ? answer : verdict.value; + } + return answer; + }, + + async confirm(question, defaultYes = true) { + asked.confirm.push(question); + return queues.confirm.length ? queues.confirm.shift() : defaultYes; + }, + + async select(question, options, defaultValue) { + asked.select.push({ question, options, defaultValue }); + return queues.select.length ? queues.select.shift() : defaultValue; + }, + + async pressEnter() { + asked.pressEnter += 1; + }, + }; +} + +/** Everything the io printed, as one string — for asserting on wording. */ +function captureLog() { + const lines = []; + const spy = jest.spyOn(console, 'log').mockImplementation((...args) => { + lines.push(args.join(' ')); + }); + return { + get text() { return lines.join('\n'); }, + restore: () => spy.mockRestore(), + }; +} + +module.exports = { fakeIo, captureLog }; diff --git a/tests/setup/fields.test.js b/tests/setup/fields.test.js new file mode 100644 index 0000000..c3cd399 --- /dev/null +++ b/tests/setup/fields.test.js @@ -0,0 +1,91 @@ +/** + * fields.js — the human copy for every value the CLI collects. + * + * The guard that matters here is coverage: `promptForTargetVars` and the wizard + * both drive their prompts from this file, so a var added to + * `CHANNEL_REQUIRED_VARS` without a matching entry would simply never be asked + * for — and the only symptom would be a deployment that fails its own + * validate:env after a setup that reported success. + */ + +const fields = require('../../bot/scripts/setup/fields'); +const validators = require('../../bot/scripts/setup/validators'); +const { CHANNEL_REQUIRED_VARS } = require('../../bot/shared/config/feature-availability'); + +describe('Meta field coverage', () => { + it('covers exactly the vars the meta channel requires — no more, no fewer', () => { + const described = fields.META_FIELDS.map((f) => f.env).sort(); + expect(described).toEqual([...CHANNEL_REQUIRED_VARS.meta].sort()); + }); + + it('gives every field a human label that is not its env var', () => { + for (const field of fields.META_FIELDS) { + expect(field.label).toBeTruthy(); + expect(field.label).not.toBe(field.env); + expect(field.label).not.toMatch(/_/); + } + }); + + it('tells you where to find each value, not just what it is called', () => { + for (const field of fields.META_FIELDS) { + // Every one of these lives on a specific page of Meta's console; a label + // without a location is the thing that sends people to a search engine. + expect(field.hint).toBeTruthy(); + expect(field.hint.length).toBeGreaterThan(40); + } + }); + + it('shape-checks every field', () => { + for (const field of fields.META_FIELDS) { + expect(typeof field.validate).toBe('function'); + expect(field.validate('').ok).toBe(false); + } + }); + + it('masks the access token and nothing that is merely an id', () => { + const secrets = fields.META_FIELDS.filter((f) => f.secret).map((f) => f.env); + expect(secrets).toEqual(['WHATSAPP_TOKEN']); + }); + + it('can generate the one value the user is supposed to invent', () => { + const webhook = fields.META_FIELDS.find((f) => f.env === 'WEBHOOK_VERIFY_TOKEN'); + const generated = webhook.generate(); + expect(validators.webhookVerifyToken(generated).ok).toBe(true); + expect(webhook.generate()).not.toBe(generated); // fresh each time + }); +}); + +describe('fieldsFor', () => { + it('asks nothing for a sandbox channel — having nothing to register is the point', () => { + expect(fields.fieldsFor('baileys')).toEqual([]); + }); + + it('has no credentials to collect for a driver it has never heard of', () => { + expect(fields.fieldsFor('some-future-channel')).toEqual([]); + }); +}); + +describe('the remaining Meta steps', () => { + it('names the webhook subscription, which is the silent failure of Meta setup', () => { + // Meta accepts a callback URL without a field subscription and then never + // sends anything, which is indistinguishable from a broken bot. + const text = fields.META_REMAINING_STEPS.join(' '); + expect(text).toMatch(/subscribe/i); + expect(text).toMatch(/messages/); + expect(text).toMatch(/webhook/i); + }); +}); + +describe('optional extras', () => { + it('lists keys that the platform actually gates a feature on', () => { + const { FEATURES } = require('../../bot/shared/config/feature-availability'); + const gatingKeys = new Set(FEATURES.flatMap((f) => [...(f.keys || []), ...(f.keysAny || [])])); + for (const extra of fields.OPTIONAL_EXTRAS) { + for (const key of extra.keys) expect(gatingKeys).toContain(key); + } + }); + + it('says where to get each key', () => { + for (const extra of fields.OPTIONAL_EXTRAS) expect(extra.where).toBeTruthy(); + }); +}); diff --git a/tests/setup/graduate.test.js b/tests/setup/graduate.test.js new file mode 100644 index 0000000..1826950 --- /dev/null +++ b/tests/setup/graduate.test.js @@ -0,0 +1,184 @@ +/** + * graduate.js — the `rumi graduate` command. Covers the pure/testable pieces: + * arg parsing, credential validation (via doctor's own probe, mocked), + * and outgoing-state retirement. The interactive prompt loop is exercised + * with a fake readline interface rather than real stdin. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + parseArgs, validateTargetCredentials, retireOutgoingDriverState, promptForTargetVars, printManualChecklist, +} = require('../../bot/scripts/setup/graduate'); + +describe('parseArgs', () => { + it('parses --to=meta into { to: "meta" }', () => { + expect(parseArgs(['node', 'graduate.js', '--to=meta'])).toEqual({ to: 'meta' }); + }); + + it('returns {} when no flags are given', () => { + expect(parseArgs(['node', 'graduate.js'])).toEqual({}); + }); +}); + +describe('validateTargetCredentials', () => { + it('skips live validation for a non-meta target (no probe exists yet)', async () => { + const result = await validateTargetCredentials('baileys', {}); + expect(result.ok).toBe(true); + }); + + it('delegates to doctor.js\'s own WhatsApp probe for meta, reporting pass', async () => { + jest.resetModules(); + jest.doMock('../../bot/scripts/setup/doctor', () => ({ + runDoctor: jest.fn().mockResolvedValue({ + probeResults: [{ name: 'WhatsApp Cloud API', status: 'pass', detail: 'HTTP 200' }], + }), + })); + const { validateTargetCredentials: reloaded } = require('../../bot/scripts/setup/graduate'); + const result = await reloaded('meta', { WHATSAPP_TOKEN: 'x', PHONE_NUMBER_ID: 'y' }); + expect(result).toEqual({ ok: true, detail: 'HTTP 200' }); + }); + + it('reports failure when doctor\'s probe fails (bad credentials)', async () => { + jest.resetModules(); + jest.doMock('../../bot/scripts/setup/doctor', () => ({ + runDoctor: jest.fn().mockResolvedValue({ + probeResults: [{ name: 'WhatsApp Cloud API', status: 'fail', detail: 'HTTP 401' }], + }), + })); + const { validateTargetCredentials: reloaded } = require('../../bot/scripts/setup/graduate'); + const result = await reloaded('meta', {}); + expect(result).toEqual({ ok: false, detail: 'HTTP 401' }); + }); +}); + +describe('retireOutgoingDriverState', () => { + it('renames / to .retired rather than deleting it', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-graduate-test-')); + fs.mkdirSync(path.join(stateDir, 'baileys'), { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'baileys', 'creds.json'), '{}'); + + const result = retireOutgoingDriverState('baileys', { CHANNEL_STATE_DIR: stateDir }); + + expect(fs.existsSync(path.join(stateDir, 'baileys'))).toBe(false); + expect(fs.existsSync(path.join(stateDir, 'baileys.retired'))).toBe(true); + // Kept, not removed: graduation is reversible if the new channel disappoints. + expect(fs.readFileSync(path.join(stateDir, 'baileys.retired', 'creds.json'), 'utf-8')).toBe('{}'); + expect(result.to).toContain('baileys.retired'); + }); + + it('returns null when there is no local state to retire (e.g. graduating from meta)', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-graduate-test-')); + expect(retireOutgoingDriverState('meta', { CHANNEL_STATE_DIR: stateDir })).toBeNull(); + }); + + it('resolves a RELATIVE state dir against the repo, not the working directory', () => { + // Must match baileys-connection.js's authDir(), which is repo-anchored for + // the same reason: resolved against cwd, `cd bot && rumi graduate` would + // "retire" bot/.channel-state and leave the live session in place. The + // cwd-relative version of this produced a second WhatsApp device on a live + // account. + // + // Uses a test-only directory name. An earlier version of this test used the + // real '.channel-state' and, by working exactly as intended, renamed the + // developer's live WhatsApp session — a test must not be able to do that + // even when it passes. + const RELATIVE = '.rumi-test-state'; + const repoCopy = path.resolve(__dirname, '../..', RELATIVE); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-graduate-cwd-')); + const decoy = path.join(elsewhere, RELATIVE); + const original = process.cwd(); + + fs.mkdirSync(path.join(repoCopy, 'baileys'), { recursive: true }); + fs.mkdirSync(path.join(decoy, 'baileys'), { recursive: true }); + process.chdir(elsewhere); + try { + const result = retireOutgoingDriverState('baileys', { CHANNEL_STATE_DIR: RELATIVE }); + + // The repo's copy is the one that moved... + expect(fs.existsSync(path.join(repoCopy, 'baileys.retired'))).toBe(true); + expect(result.from).toBe(path.join(repoCopy, 'baileys')); + // ...and the directory that merely happened to be the cwd is untouched. + expect(fs.existsSync(path.join(decoy, 'baileys'))).toBe(true); + expect(fs.existsSync(path.join(decoy, 'baileys.retired'))).toBe(false); + } finally { + process.chdir(original); + fs.rmSync(repoCopy, { recursive: true, force: true }); + fs.rmSync(elsewhere, { recursive: true, force: true }); + } + }); +}); + +describe('promptForTargetVars', () => { + const { fakeIo } = require('./fake-io'); + + it("asks for the target's credentials by their human names, prefilling any real existing value", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-graduate-test-')); + const envPath = path.join(cwd, '.env'); + fs.writeFileSync(envPath, 'WHATSAPP_TOKEN=CHANGEME-token\nPHONE_NUMBER_ID=123456789012345\n'); + + const io = fakeIo(); + const result = await promptForTargetVars(io, 'meta', envPath); + + expect(Object.keys(result)).toEqual(['WHATSAPP_TOKEN', 'PHONE_NUMBER_ID', 'WABA_ID', 'WEBHOOK_VERIFY_TOKEN']); + + // Asked in Meta's own words, never by env-var name. + const labels = io.asked.ask.map((a) => a.label); + expect(labels).toEqual(['Access token', 'Phone number ID', 'WhatsApp Business Account ID', 'Webhook password']); + + const byLabel = Object.fromEntries(io.asked.ask.map((a) => [a.label, a])); + // A CHANGEME placeholder is not a value — it must not be offered back. + expect(byLabel['Access token'].fallback).toBe(''); + // A real existing value is, so Enter keeps it. + expect(byLabel['Phone number ID'].fallback).toBe('123456789012345'); + expect(result.PHONE_NUMBER_ID).toBe('123456789012345'); + }); + + it('carries the same shape checks as `rumi setup`, so the two cannot disagree', async () => { + const io = fakeIo(); + await promptForTargetVars(io, 'meta', path.join(os.tmpdir(), 'does-not-exist')); + const byLabel = Object.fromEntries(io.asked.ask.map((a) => [a.label, a])); + + // Asserted by behaviour rather than by function identity: earlier tests in + // this file reset the module registry, so the same validator legitimately + // arrives as a different function object. + const phone = byLabel['Phone number ID'].validate('923001234567'); + expect(phone.ok).toBe(false); + expect(phone.reason).toMatch(/looks like the phone number/i); + + expect(byLabel['Access token'].validate('sk-ant-nope').ok).toBe(false); + expect(byLabel['Access token'].validate(`EAA${'x'.repeat(150)}`).ok).toBe(true); + }); + + it('hides the access token while it is typed', async () => { + const io = fakeIo(); + await promptForTargetVars(io, 'meta', path.join(os.tmpdir(), 'does-not-exist')); + const byLabel = Object.fromEntries(io.asked.ask.map((a) => [a.label, a])); + expect(byLabel['Access token'].secret).toBe(true); + expect(byLabel['WhatsApp Business Account ID'].secret).toBe(false); + }); + + it('asks for nothing when the target needs no credentials (any sandbox channel)', async () => { + const io = fakeIo(); + const result = await promptForTargetVars(io, 'baileys'); + expect(result).toEqual({}); + expect(io.asked.ask).toHaveLength(0); + }); +}); + +describe('printManualChecklist', () => { + it('says out loud that the production number is a different number', async () => { + // The one thing graduation cannot carry over. Users are keyed by phone + // number so all their data follows them, which makes it easy to assume the + // number does too — and then existing testers message a dead line. + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + printManualChecklist('meta'); + const printed = logSpy.mock.calls.join('\n'); + logSpy.mockRestore(); + + expect(printed).toMatch(/different number/i); + expect(printed).toMatch(/webhook/i); + }); +}); diff --git a/tests/setup/interactive-setup.test.js b/tests/setup/interactive-setup.test.js new file mode 100644 index 0000000..910f25e --- /dev/null +++ b/tests/setup/interactive-setup.test.js @@ -0,0 +1,508 @@ +/** + * interactive-setup.js — the `rumi setup` wizard. + * + * Tested as behaviour through a fake io (tests/setup/fake-io.js) rather than by + * grepping the source, because the guarantees worth protecting here are about + * what the wizard *does*: which questions it asks, in whose words, what it + * writes to .env, and whether a re-run is cheap. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { fakeIo, captureLog } = require('./fake-io'); + +const WIZARD = '../../bot/scripts/setup/interactive-setup'; + +/** + * Loads the wizard with chosen collaborators stubbed. The wizard requires + * `./doctor` lazily (inside `probe`) and `./db-setup` eagerly, so mocks have to + * be installed before the require either way. + * + * @param {{probes?: object, dbSetup?: object}} mocks + */ +function loadWizard(mocks = {}) { + jest.resetModules(); + if (mocks.probes || mocks.doctor) { + jest.doMock('../../bot/scripts/setup/doctor', () => ({ + defaultProbes: mocks.probes || {}, + runDoctor: (mocks.doctor && mocks.doctor.runDoctor) || jest.fn(), + })); + } + if (mocks.dbSetup) { + const real = jest.requireActual('../../bot/scripts/setup/db-setup'); + jest.doMock('../../bot/scripts/setup/db-setup', () => ({ ...real, ...mocks.dbSetup })); + } + // eslint-disable-next-line global-require + return require(WIZARD); +} + +const allPass = { + supabase: async () => ({ ok: true, detail: 'HTTP 200' }), + openrouter: async () => ({ ok: true, detail: 'HTTP 200 · $5.00 credit remaining' }), + redis: async () => ({ ok: true, detail: 'PONG' }), + whatsapp: async () => ({ ok: true, detail: 'HTTP 200' }), +}; + +let log; +beforeEach(() => { log = captureLog(); }); +afterEach(() => { log.restore(); jest.restoreAllMocks(); }); + +describe('the channel question', () => { + it('is asked in plain language and never names CHANNEL_DRIVER', async () => { + const { chooseChannelDriver } = loadWizard(); + const io = fakeIo(); + await chooseChannelDriver(io); + + const { question, options } = io.asked.select[0]; + expect(question).toMatch(/how are you using rumi/i); + const everythingShown = [question, ...options.map((o) => `${o.label} ${o.hint}`)].join(' '); + expect(everythingShown).not.toMatch(/CHANNEL_DRIVER|baileys|sandbox|driver/i); + }); + + it('defaults to the sandbox — trying Rumi out must not require a Meta account', async () => { + const { chooseChannelDriver } = loadWizard(); + expect(await chooseChannelDriver(fakeIo())).toBe('baileys'); + }); + + it('resolves to meta when the user picks the real-deployment option', async () => { + const { chooseChannelDriver } = loadWizard(); + expect(await chooseChannelDriver(fakeIo({ select: ['meta'] }))).toBe('meta'); + }); +}); + +describe('stepChannel', () => { + /** Collects what would have been written to .env. */ + function recordingSaver(env = {}) { + const saved = {}; + return { env, saved, save: (vars) => { Object.assign(saved, vars); Object.assign(env, vars); } }; + } + + it('gives the sandbox a queue it can actually run — bullmq, on the Redis just configured', async () => { + const { stepChannel } = loadWizard({ probes: allPass }); + const { env, saved, save } = recordingSaver(); + // Decline the QR so the test never reaches the real WhatsApp connection. + const io = fakeIo({ select: ['baileys'], confirm: [false] }); + + const result = await stepChannel(io, env, save); + + expect(result.channel).toBe('baileys'); + expect(saved.CHANNEL_DRIVER).toBe('baileys'); + // The template default is sqs, which needs an AWS account a sandbox user + // does not have — a quiz that generated AND delivered still reported itself + // as failed, because scheduling its report threw "SQS Queue not configured". + expect(saved.QUEUE_DRIVER).toBe('bullmq'); + expect(saved.CHANNEL_STATE_DIR).toBe('.channel-state'); + }); + + it('leaves a production deployment\'s queue choice alone', async () => { + const { stepChannel } = loadWizard({ probes: allPass }); + const { env, saved, save } = recordingSaver(); + const io = fakeIo({ + select: ['meta'], + ask: ['EAA'.padEnd(150, 'x'), '123456789012345', '987654321098765', 'a-webhook-password'], + }); + + const result = await stepChannel(io, env, save); + + expect(result.channel).toBe('meta'); + expect(saved.QUEUE_DRIVER).toBeUndefined(); + expect(saved.WHATSAPP_TOKEN).toMatch(/^EAA/); + expect(saved.PHONE_NUMBER_ID).toBe('123456789012345'); + }); + + it('asks for Meta\'s credentials by what they are called on Meta\'s own page', async () => { + const { collectMetaCredentials } = loadWizard({ probes: allPass }); + const io = fakeIo({ ask: ['EAA'.padEnd(150, 'x'), '123456789012345', '987654321098765', 'a-webhook-password'] }); + + await collectMetaCredentials(io, {}, () => {}); + + const labels = io.asked.ask.map((a) => a.label); + expect(labels).toEqual(['Access token', 'Phone number ID', 'WhatsApp Business Account ID', 'Webhook password']); + expect(labels.join(' ')).not.toMatch(/WHATSAPP_TOKEN|PHONE_NUMBER_ID|WABA_ID/); + }); + + it('offers a generated webhook password, so nobody has to invent one', async () => { + const { collectMetaCredentials } = loadWizard({ probes: allPass }); + const io = fakeIo({ ask: ['EAA'.padEnd(150, 'x'), '123456789012345', '987654321098765', ''] }); + const saved = {}; + + await collectMetaCredentials(io, {}, (vars) => Object.assign(saved, vars)); + + expect(saved.WEBHOOK_VERIFY_TOKEN).toMatch(/^[0-9a-f]{32}$/); + }); + + it('hides the access token as it is typed, and does not hide the ids', async () => { + const { collectMetaCredentials } = loadWizard({ probes: allPass }); + const io = fakeIo({ ask: ['EAA'.padEnd(150, 'x'), '123456789012345', '987654321098765', 'pw123456'] }); + + await collectMetaCredentials(io, {}, () => {}); + + const byLabel = Object.fromEntries(io.asked.ask.map((a) => [a.label, Boolean(a.secret)])); + expect(byLabel['Access token']).toBe(true); + expect(byLabel['Phone number ID']).toBe(false); + }); +}); + +describe('re-running the wizard', () => { + it('does not re-ask for a database that already works', async () => { + const { stepDatabase } = loadWizard({ + probes: allPass, + dbSetup: { inspectDatabase: async () => ({ state: 'ready', detail: 'the "users" table is already there' }) }, + }); + const io = fakeIo(); + + await stepDatabase(io, { SUPABASE_URL: 'https://x.supabase.co', SUPABASE_SERVICE_ROLE_KEY: 'eyJok' }, () => {}); + + expect(io.asked.ask).toHaveLength(0); + expect(log.text).toMatch(/already connected/i); + }); + + it('re-asks anyway when --reconfigure was passed', async () => { + const { stepDatabase } = loadWizard({ + probes: allPass, + dbSetup: { inspectDatabase: async () => ({ state: 'ready', detail: 'ok' }) }, + }); + const io = fakeIo({ ask: ['https://new.supabase.co', `eyJ${Buffer.from('{"role":"service_role"}').toString('base64')}.x`] }); + + await stepDatabase(io, { SUPABASE_URL: 'https://old.supabase.co', SUPABASE_SERVICE_ROLE_KEY: 'eyJold' }, () => {}, { reconfigure: true }); + + expect(io.asked.ask.map((a) => a.label)).toEqual(['Project URL', 'Service key']); + }); + + it('re-asks when the stored credentials have stopped working', async () => { + const { stepDatabase } = loadWizard({ + probes: { ...allPass, supabase: (() => { let call = 0; return async () => { call += 1; return call === 1 ? { ok: false, detail: 'HTTP 401' } : { ok: true, detail: 'HTTP 200' }; }; })() }, + dbSetup: { inspectDatabase: async () => ({ state: 'ready', detail: 'ok' }) }, + }); + const io = fakeIo({ ask: ['https://new.supabase.co', 'sb_secret_abc'] }); + + await stepDatabase(io, { SUPABASE_URL: 'https://old.supabase.co', SUPABASE_SERVICE_ROLE_KEY: 'eyJold' }, () => {}); + + expect(io.asked.ask).toHaveLength(2); + }); +}); + +describe('creating the tables', () => { + it('does nothing when they are already there', async () => { + const applySchema = jest.fn(); + const { ensureTables } = loadWizard({ + probes: allPass, + dbSetup: { inspectDatabase: async () => ({ state: 'ready', detail: 'ok' }), applySchema }, + }); + + await ensureTables(fakeIo(), { SUPABASE_URL: 'https://x.supabase.co' }); + + expect(applySchema).not.toHaveBeenCalled(); + expect(log.text).toMatch(/already set up/i); + }); + + it('shows the one-time SQL and a link to the right project\'s editor when the helper is missing', async () => { + // Supabase has no API for running arbitrary SQL, so `exec_sql` must be + // pasted in by hand once. This is the step a self-serve setup dies on when + // it is glossed over, so the wizard has to name it and link straight to it. + const applySchema = jest.fn(async () => ({ ok: true, applied: ['00_complete-schema.sql'], errors: [] })); + const { ensureTables } = loadWizard({ + probes: allPass, + dbSetup: { + inspectDatabase: async () => ({ state: 'needs-helper', detail: 'exec_sql is missing (HTTP 404)' }), + hasExecSql: async () => ({ present: true, detail: 'exec_sql answered' }), + applySchema, + }, + }); + const io = fakeIo(); + + await ensureTables(io, { SUPABASE_URL: 'https://abcdefgh.supabase.co' }); + + expect(log.text).toContain('create or replace function exec_sql'); + expect(log.text).toContain('https://supabase.com/dashboard/project/abcdefgh/sql/new'); + expect(io.asked.pressEnter).toBe(1); + expect(applySchema).toHaveBeenCalled(); + }); + + it('carries on with a clear next step when the helper still is not there', async () => { + const applySchema = jest.fn(); + const { ensureTables } = loadWizard({ + probes: allPass, + dbSetup: { + inspectDatabase: async () => ({ state: 'needs-helper', detail: 'missing' }), + hasExecSql: async () => ({ present: false, detail: 'still missing' }), + applySchema, + }, + }); + + await ensureTables(fakeIo(), { SUPABASE_URL: 'https://abcdefgh.supabase.co' }); + + expect(applySchema).not.toHaveBeenCalled(); + expect(log.text).toMatch(/bootstrap:db/); + }); + + it('reports a failed schema apply without aborting the rest of setup', async () => { + const { ensureTables } = loadWizard({ + probes: allPass, + dbSetup: { + inspectDatabase: async () => ({ state: 'needs-schema', detail: 'helper present' }), + applySchema: async () => ({ ok: false, applied: [], errors: [{ file: '00_complete-schema.sql', error: 'statement timeout' }] }), + }, + }); + + // The guarantee is that it does not throw: a database that half-applied is a + // problem to report and retry, not a reason to lose the four other steps. + await expect(ensureTables(fakeIo(), { SUPABASE_URL: 'https://x.supabase.co' })).resolves.toBeUndefined(); + expect(log.text).toMatch(/statement timeout/); + expect(log.text).toMatch(/bootstrap:db/); + }); +}); + +describe('the AI step', () => { + it('treats "valid key, no credit" as a question, not a rejection', async () => { + // The key is fine; the account cannot pay for a request. Re-asking for the + // key would be nonsense — there is nothing wrong with it. + const { stepBrain } = loadWizard({ + probes: { + ...allPass, + openrouter: async () => ({ ok: false, detail: 'key valid, but the account has no credits — add some at openrouter.ai/settings/credits' }), + }, + }); + const io = fakeIo({ ask: ['sk-or-v1-abcdefghijklmnop'], confirm: [true] }); + + await stepBrain(io, {}, () => {}); + + expect(io.asked.ask).toHaveLength(1); + expect(io.asked.confirm[0]).toMatch(/carry on/i); + }); + + it('re-asks when the key itself is rejected', async () => { + let calls = 0; + const { stepBrain } = loadWizard({ + probes: { + ...allPass, + openrouter: async () => { calls += 1; return calls === 1 ? { ok: false, detail: 'HTTP 401' } : { ok: true, detail: 'HTTP 200' }; }, + }, + }); + const io = fakeIo({ ask: ['sk-or-v1-wrongkeyvalue', 'sk-or-v1-rightkeyvalue'] }); + + await stepBrain(io, {}, () => {}); + + expect(io.asked.ask).toHaveLength(2); + }); +}); + +describe('the Redis step', () => { + it('reuses the container from a previous run instead of failing on the name', () => { + // Someone re-running setup would otherwise hit "the container name is + // already in use" and be stranded at a Docker error mid-tutorial. + const { startLocalRedis } = loadWizard(); + const run = jest.fn() + .mockReturnValueOnce({ status: 1, stderr: 'docker: Error response from daemon: Conflict. The container name "/rumi-redis" is already in use' }) + .mockReturnValueOnce({ status: 0, stdout: 'rumi-redis' }); + + const result = startLocalRedis(run); + + expect(result.ok).toBe(true); + expect(run.mock.calls[1][1]).toEqual(['start', 'rumi-redis']); + }); + + it('reports the docker error rather than a generic failure', () => { + const { startLocalRedis } = loadWizard(); + const run = jest.fn().mockReturnValue({ status: 1, stderr: 'Cannot connect to the Docker daemon' }); + + expect(startLocalRedis(run)).toEqual({ ok: false, detail: 'Cannot connect to the Docker daemon' }); + }); +}); + +describe('saving progress', () => { + it('writes each answer to .env as it is given, so Ctrl+C costs nothing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-setup-test-')); + const envPath = path.join(dir, '.env'); + fs.writeFileSync(envPath, '# existing\nOTHER_VAR=keep-me\n'); + + const { createSaver } = loadWizard(); + const env = {}; + const save = createSaver(env, envPath); + + save({ SUPABASE_URL: 'https://x.supabase.co' }); + save({ REDIS_URL: 'redis://localhost:6379' }); + + const written = fs.readFileSync(envPath, 'utf-8'); + expect(written).toContain('SUPABASE_URL=https://x.supabase.co'); + expect(written).toContain('REDIS_URL=redis://localhost:6379'); + expect(written).toContain('OTHER_VAR=keep-me'); + // The live env is updated too, so the next step's check sees this one's answer. + expect(env.SUPABASE_URL).toBe('https://x.supabase.co'); + }); + + it('never writes an empty value over something already set', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-setup-test-')); + const envPath = path.join(dir, '.env'); + fs.writeFileSync(envPath, 'SONIOX_API_KEY=already-here\n'); + + const { createSaver } = loadWizard(); + createSaver({}, envPath)({ SONIOX_API_KEY: '' }); + + expect(fs.readFileSync(envPath, 'utf-8')).toContain('SONIOX_API_KEY=already-here'); + }); +}); + +describe('hasAll', () => { + it('treats a CHANGEME placeholder as not set — the template ships those', () => { + const { hasAll } = loadWizard(); + expect(hasAll({ WHATSAPP_TOKEN: 'CHANGEME-your-token' }, ['WHATSAPP_TOKEN'])).toBe(false); + expect(hasAll({ WHATSAPP_TOKEN: 'EAAreal' }, ['WHATSAPP_TOKEN'])).toBe(true); + expect(hasAll({}, ['WHATSAPP_TOKEN'])).toBe(false); + }); +}); + +describe('optional extras', () => { + it('defaults to skipping — Rumi works without any of them', async () => { + const { stepExtras } = loadWizard(); + const io = fakeIo(); + + await stepExtras(io, {}, () => {}); + + expect(io.asked.select[0].defaultValue).toBe('skip'); + expect(io.asked.ask).toHaveLength(0); + }); + + it('describes each one by what a teacher would notice, not by the vendor', async () => { + const { OPTIONAL_EXTRAS } = require('../../bot/scripts/setup/fields'); + for (const extra of OPTIONAL_EXTRAS) { + expect(extra.title).not.toMatch(/API|KEY|_/); + expect(extra.why.length).toBeGreaterThan(40); + } + }); + + it('only stores a multi-key extra when every one of its keys was given', async () => { + // Azure needs a key AND a region; half of it configured is a feature that + // reports itself available and then fails at runtime. + const { stepExtras } = loadWizard(); + const saved = {}; + const io = fakeIo({ select: ['add'], ask: ['soniox-key', '', '', '', 'azure-key', '', ''] }); + + await stepExtras(io, {}, (vars) => Object.assign(saved, vars)); + + expect(saved.SONIOX_API_KEY).toBe('soniox-key'); + expect(saved.AZURE_SPEECH_KEY).toBeUndefined(); + }); +}); + +describe('the template is a set of suggestions, not answers', () => { + // `.env` is created by copying `.env.template`, which ships working-looking + // values (`redis://localhost:6379`, `https://your-project.supabase.co`). + // Counting those as configuration produced three wrong things in a live + // fresh-clone run, each fixed and pinned below. + const FRESH_ENV = { + SUPABASE_URL: 'https://your-project.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'CHANGEME-supabase-service-role-key', + OPENROUTER_API_KEY: 'CHANGEME-sk-or-v1-your-openrouter-key', + REDIS_URL: 'redis://localhost:6379', + CHANNEL_DRIVER: 'baileys', + }; + + it('does not count a template placeholder as a configured service', () => { + const { isProvided } = loadWizard(); + expect(isProvided(FRESH_ENV, 'SUPABASE_URL')).toBe(false); + expect(isProvided(FRESH_ENV, 'REDIS_URL')).toBe(false); + expect(isProvided(FRESH_ENV, 'OPENROUTER_API_KEY')).toBe(false); + expect(isProvided({ REDIS_URL: 'redis://default:pw@host.rlwy.net:38580' }, 'REDIS_URL')).toBe(true); + }); + + it('never claims "picking up from last time" on a fresh clone', () => { + const { welcome } = loadWizard(); + welcome(FRESH_ENV); + expect(log.text).not.toMatch(/picking up/i); + }); + + it('says it is picking up when a real value is there', () => { + const { welcome } = loadWizard(); + welcome({ ...FRESH_ENV, OPENROUTER_API_KEY: 'sk-or-v1-real' }); + expect(log.text).toMatch(/picking up/i); + }); + + it('never offers a placeholder as the value to keep', async () => { + const { stepDatabase } = loadWizard({ + probes: allPass, + dbSetup: { inspectDatabase: async () => ({ state: 'ready', detail: 'ok' }) }, + }); + const io = fakeIo({ ask: ['https://real.supabase.co', 'sb_secret_real'] }); + + await stepDatabase(io, { ...FRESH_ENV }, () => {}); + + // Pressing Enter on `[https://your-project.supabase.co]` would accept a + // placeholder as configuration. + for (const asked of io.asked.ask) expect(asked.fallback).toBe(''); + }); + + it('tries the template\'s local Redis quietly, without a red cross for something nobody configured', async () => { + // Fails for the template's localhost, succeeds for the address the user + // pastes — the wizard re-asks until Redis answers, so a probe that always + // fails would loop forever (as it should, with a human at the keyboard). + let call = 0; + const { stepMemory } = loadWizard({ + probes: { + ...allPass, + redis: async () => { + call += 1; + return call === 1 + ? { ok: false, detail: 'nothing answered at redis://localhost:6379' } + : { ok: true, detail: 'PONG' }; + }, + }, + }); + const io = fakeIo({ ask: ['redis://default:pw@host.rlwy.net:38580'] }); + + await stepMemory(io, { ...FRESH_ENV }, () => {}); + + expect(log.text).not.toMatch(/Redis you already have/); + expect(log.text).not.toMatch(/not answering/); + expect(io.asked.ask).toHaveLength(1); + }); + + it('keeps the template\'s local Redis when one really is running there', async () => { + const { stepMemory } = loadWizard({ probes: allPass }); + const io = fakeIo(); + + await stepMemory(io, { ...FRESH_ENV }, () => {}); + + expect(io.asked.ask).toHaveLength(0); + expect(log.text).toMatch(/already running at redis:\/\/localhost:6379/); + }); + + it('still asks which channel to use, rather than inheriting the template default', async () => { + const { stepChannel } = loadWizard({ probes: allPass }); + const io = fakeIo({ select: ['baileys'], confirm: [false] }); + + await stepChannel(io, { ...FRESH_ENV }, () => {}); + + expect(io.asked.select).toHaveLength(1); + }); +}); + +describe('the Redis step is not a dead end', () => { + it('says where to get one when Docker is not available to start one', async () => { + // Live fresh-clone run on a machine with no Docker daemon: the step asked + // for an address and explained its format, but never said how someone with + // no Redis at all was meant to get one. Redis is required, so that is a + // blocked setup, not an inconvenience. + const { stepMemory } = loadWizard({ probes: { ...allPass, redis: async () => ({ ok: true, detail: 'PONG' }) } }); + const io = fakeIo({ ask: ['redis://default:pw@host:6379'] }); + + await stepMemory(io, {}, () => {}, { dockerAvailable: () => false }); + + expect(log.text).toMatch(/upstash\.com/); + expect(log.text).toMatch(/docker run/); + expect(io.asked.select).toHaveLength(0); // a one-item menu is not a question + }); + + it('offers to start one locally when Docker is there, instead of the where-to-get-it list', async () => { + const { stepMemory } = loadWizard({ probes: { ...allPass, redis: async () => ({ ok: true, detail: 'PONG' }) } }); + const io = fakeIo({ select: ['paste'], ask: ['redis://default:pw@host:6379'] }); + + await stepMemory(io, {}, () => {}, { dockerAvailable: () => true }); + + expect(io.asked.select).toHaveLength(1); + expect(io.asked.select[0].options.map((o) => o.value)).toEqual(['docker', 'paste']); + expect(log.text).not.toMatch(/upstash\.com/); + }); +}); diff --git a/tests/setup/llm-single-entry-point.test.js b/tests/setup/llm-single-entry-point.test.js new file mode 100644 index 0000000..4405028 --- /dev/null +++ b/tests/setup/llm-single-entry-point.test.js @@ -0,0 +1,124 @@ +/** + * Conformance guard: chat-completion calls go through shared/services/llm-client. + * + * CLAUDE.md states it as an architecture fact — "All LLM calls go through + * bot/shared/services/llm-client.js (OpenRouter — one API, many models)" — but + * nothing enforced it, and four services had drifted into constructing their own + * OpenAI client keyed on OPENAI_API_KEY. On a deployment configured the + * documented way (OPENROUTER_API_KEY set, OPENAI_API_KEY empty) each of them + * failed, and each failure surfaced as a broken feature rather than as a + * configuration error: + * + * quiz-generation.service.js → "/quiz" could not generate a quiz + * pic-to-lp/classifier.service.js → every inbound image failed to classify + * pic-to-lp/metadata-extractor → same pipeline, next stage + * coaching/transcript-enhancer → coaching transcripts un-enhanced + * coaching/coaching-helpers → no post-session encouragement message + * + * Legitimately exempt: anything that is not a chat completion. OpenAI-only + * endpoints (audio transcription) and non-OpenAI SDKs keep their own clients. + */ + +const fs = require('fs'); +const path = require('path'); + +const BOT_DIR = path.resolve(__dirname, '../../bot'); +const SEARCH_DIRS = ['shared', 'workers'].map((d) => path.join(BOT_DIR, d)); + +/** + * Files allowed to build their own OpenAI-shaped client, with the reason. + * Keep this list SHORT and justified — it is the escape hatch, not the norm. + */ +const ALLOWED = new Map([ + ['shared/services/llm-client.js', 'is the single entry point'], + ['shared/utils/lazy-client.js', 'generic lazy-construction helper; its OpenAI mention is a doc example'], + [ + 'shared/services/coaching/reflective-questions/llm-router.service.js', + 'builds an OpenRouter client directly from OPENROUTER_API_KEY (right provider, own routing needs)', + ], + // Not chat completions: OpenAI-only endpoints that OpenRouter does not proxy. + ['shared/services/audio.service.js', 'openai.audio.transcriptions (Whisper) — OpenAI-only endpoint'], + ['shared/services/elevenlabs.service.js', 'openai.audio.speech (TTS fallback) — OpenAI-only endpoint'], +]); + +function listJsFiles(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '__mocks__') continue; + out.push(...listJsFiles(full)); + } else if (entry.name.endsWith('.js')) { + out.push(full); + } + } + return out; +} + +/** Comments stripped, so a file may describe the wrong pattern in prose. */ +function codeOf(file) { + return fs.readFileSync(file, 'utf-8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .map((line) => (/^\s*(\/\/|\*)/.test(line) ? '' : line)) + .join('\n'); +} + +const files = SEARCH_DIRS.flatMap(listJsFiles); + +describe('LLM single entry point', () => { + it('finds files to check', () => { + expect(files.length).toBeGreaterThan(50); + }); + + it('no service builds its own OpenAI client keyed on OPENAI_API_KEY', () => { + const violations = []; + + for (const file of files) { + const rel = path.relative(BOT_DIR, file).split(path.sep).join('/'); + if (ALLOWED.has(rel)) continue; + + const code = codeOf(file); + // Both spellings that were found in the wild. + const direct = /new OpenAI\s*\(/.test(code); + const lazy = /lazyClient\(\s*OpenAI\s*,\s*\[\s*'OPENAI_API_KEY'/.test(code); + if (direct || lazy) violations.push(rel); + } + + expect(violations).toEqual([]); + }); + + it('the services that regressed now import llm-client', () => { + const repaired = [ + 'shared/services/quiz/quiz-generation.service.js', + 'shared/services/quiz/quiz-report.service.js', + 'shared/services/quiz/quiz-session.service.js', + 'shared/services/quiz/video-quiz-report.service.js', + 'shared/services/pic-to-lp/classifier.service.js', + 'shared/services/pic-to-lp/metadata-extractor.service.js', + 'shared/services/coaching/transcript-enhancer.service.js', + 'shared/services/coaching/coaching-helpers.service.js', + ]; + for (const rel of repaired) { + const code = codeOf(path.join(BOT_DIR, rel)); + expect(code).toMatch(/require\('\.\.?\/(\.\.\/)?llm-client'\)/); + } + }); + + it('every allow-listed file still exists (the list cannot rot silently)', () => { + for (const rel of ALLOWED.keys()) { + expect(fs.existsSync(path.join(BOT_DIR, rel))).toBe(true); + } + }); + + it('the audio exemptions really are audio endpoints, not chat completions', () => { + // Pins WHY they are exempt, so the allow-list can't quietly become a place + // to hide a chat-completion bypass. + for (const rel of ['shared/services/audio.service.js', 'shared/services/elevenlabs.service.js']) { + const code = codeOf(path.join(BOT_DIR, rel)); + expect(code).toMatch(/openai\.audio\.|this\.openai\.audio\./); + expect(code).not.toMatch(/chat\.completions\.create/); + } + }); +}); diff --git a/tests/setup/no-undefined-redis-methods.test.js b/tests/setup/no-undefined-redis-methods.test.js new file mode 100644 index 0000000..b4b5a69 --- /dev/null +++ b/tests/setup/no-undefined-redis-methods.test.js @@ -0,0 +1,128 @@ +/** + * Conformance guard: every `redisService.(` call in the bot must refer + * to a method railway-redis.service.js actually exports. + * + * Same shape, and the same motivation, as no-undefined-whatsapp-methods.test.js. + * This bug class keeps shipping because the call sites are wrapped in try/catch + * "for resilience", so a missing method looks like a transient Redis problem + * instead of a typo: + * + * - `redisService.setexWithCeiling(...)` was called from 10+ places across the + * whole quiz subsystem (session, delivery, follow-up) and did not exist, so + * NO quiz could be delivered on any deployment — picking a class after /quiz + * just said "Sorry, something went wrong." + * - `redisService.getClient()` was called three times by the exam checker and + * did not exist, so its cache silently never worked. + * + * Both were found by running the bot, not by reading it. This test finds the next + * one at CI time. + */ + +const fs = require('fs'); +const path = require('path'); + +const BOT_DIR = path.resolve(__dirname, '../../bot'); +const SERVICE_PATH = path.join(BOT_DIR, 'shared/services/cache/railway-redis.service.js'); + +/** Directories walked for call sites. */ +const SEARCH_DIRS = ['shared', 'workers', 'scripts'].map((d) => path.join(BOT_DIR, d)); + +function listJsFiles(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '__mocks__') continue; + out.push(...listJsFiles(full)); + } else if (entry.name.endsWith('.js')) { + out.push(full); + } + } + return out; +} + +/** Method names declared on the service class (`async name(` / `name(`). */ +function declaredMethods(src) { + const names = new Set(); + const re = /^\s{2}(?:static\s+)?(?:async\s+)?([a-zA-Z_$][\w$]*)\s*\(/gm; + let m; + while ((m = re.exec(src))) names.add(m[1]); + names.delete('constructor'); + names.delete('if'); + names.delete('for'); + names.delete('while'); + names.delete('catch'); + names.delete('switch'); + names.delete('return'); + return names; +} + +/** Non-method members assigned in the constructor (e.g. `this.redis = …`). */ +function declaredProperties(src) { + const names = new Set(); + const re = /this\.([a-zA-Z_$][\w$]*)\s*=/g; + let m; + while ((m = re.exec(src))) names.add(m[1]); + return names; +} + +const serviceSource = fs.readFileSync(SERVICE_PATH, 'utf-8'); +const METHODS = declaredMethods(serviceSource); +const PROPERTIES = declaredProperties(serviceSource); + +describe('railway-redis.service surface', () => { + it('exports a single instance whose methods this test can enumerate', () => { + expect(serviceSource).toMatch(/module\.exports\s*=\s*new RailwayRedisService\(\)/); + // sanity: the well-known ones are found by the parser + for (const name of ['get', 'set', 'setex', 'delete', 'isAvailable']) { + expect(METHODS).toContain(name); + } + }); + + it('implements setexWithCeiling, which the entire quiz subsystem depends on', () => { + expect(METHODS).toContain('setexWithCeiling'); + }); + + it('implements setNX, which every inbound image depends on', () => { + expect(METHODS).toContain('setNX'); + }); +}); + +describe('every redisService.() call resolves to a real method', () => { + const files = SEARCH_DIRS.flatMap(listJsFiles); + + it('finds call sites to check (guards against a silently empty sweep)', () => { + expect(files.length).toBeGreaterThan(50); + }); + + it('has no call to a method or property that does not exist', () => { + // Comments are stripped first: these files legitimately DESCRIBE the wrong + // calls in prose ("previously called redisService.getClient()"), and a guard + // that flagged its own explanation would be unfixable. + const stripComments = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .map((line) => (/^\s*(\/\/|\*)/.test(line) ? '' : line)) + .join('\n'); + + // Matches redisService.foo( and redisService.foo. — the alias forms used in + // the codebase are `redisService` and `redis` (the latter is often the raw + // ioredis client, so only the explicit service name is checked here). + const callRe = /\bredisService\s*\.\s*([a-zA-Z_$][\w$]*)/g; + const violations = []; + + for (const file of files) { + const src = stripComments(fs.readFileSync(file, 'utf-8')); + let m; + while ((m = callRe.exec(src))) { + const member = m[1]; + if (METHODS.has(member) || PROPERTIES.has(member)) continue; + const line = src.slice(0, m.index).split('\n').length; + violations.push(`${path.relative(BOT_DIR, file)}:${line} → redisService.${member}`); + } + } + + expect(violations).toEqual([]); + }); +}); diff --git a/tests/setup/no-undefined-whatsapp-methods.test.js b/tests/setup/no-undefined-whatsapp-methods.test.js index 2a1bb74..6a75a45 100644 --- a/tests/setup/no-undefined-whatsapp-methods.test.js +++ b/tests/setup/no-undefined-whatsapp-methods.test.js @@ -13,7 +13,12 @@ * This guard locks the contract: every `WhatsAppService.(` call site * in shipped bot source must name a real static member of the class. * - * Method names are parsed off bot/shared/services/whatsapp.service.js: + * Method names are parsed off bot/shared/services/messaging/meta-channel.service.js + * (the Meta driver — moved out of the old standalone whatsapp.service.js, + * which is now a thin facade over bot/shared/services/messaging/. Meta is the + * fullest/reference driver, so it's the canonical name list; the sandbox + * (Baileys) driver derives its own stub surface FROM this same class at + * require time — see baileys-channel.service.js — so it can never drift): * - static methods: ^\s*static\s+(?:async\s+)?(\w+)\s*\( * - static fields: ^\s*static\s+(\w+)\s*= * @@ -31,7 +36,7 @@ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '../..'); -const SERVICE = path.join(ROOT, 'bot/shared/services/whatsapp.service.js'); +const SERVICE = path.join(ROOT, 'bot/shared/services/messaging/meta-channel.service.js'); const SCAN_ROOT = path.join(ROOT, 'bot'); // Directories that legitimately reference mock-only or not-yet-real members. diff --git a/tests/setup/prompt.test.js b/tests/setup/prompt.test.js new file mode 100644 index 0000000..e9337cb --- /dev/null +++ b/tests/setup/prompt.test.js @@ -0,0 +1,139 @@ +/** + * prompt.js — the input layer. + * + * Keystroke handling (raw mode, masking, arrow keys) needs a real terminal and + * is verified by running the CLI; what is tested here is everything a terminal + * cannot tell you: that a validator gets a chance to reject before an answer is + * accepted, that pressing Enter means "keep what is there", and that an + * existing secret is previewed without being disclosed. + * + * `readline` is mocked so the non-TTY paths can be driven from a queue instead + * of hanging on stdin. + */ + +let mockQueued = []; + +jest.mock('readline', () => ({ + createInterface: () => ({ + question: (_prompt, callback) => callback(mockQueued.length ? mockQueued.shift() : ''), + close: () => {}, + once: () => {}, + }), +})); + +const { createIo, previewOf, readChoice, PromptAbortError } = require('../../bot/scripts/setup/prompt'); + +beforeEach(() => { mockQueued = []; jest.spyOn(console, 'log').mockImplementation(() => {}); }); +afterEach(() => { jest.restoreAllMocks(); }); + +describe('previewOf', () => { + it('shows a plain value as it is', () => { + expect(previewOf('https://x.supabase.co', false)).toBe('https://x.supabase.co'); + }); + + it('shows enough of a secret to recognise it, and not enough to reuse it', () => { + // The point is answering "is the key already there, and is it the right + // one" during a screen-share without disclosing the key. + const preview = previewOf('sk-or-v1-0123456789abcdefghij', true); + expect(preview).toBe('sk-o…ghij'); + expect(preview).not.toContain('0123456789'); + }); + + it('fully masks a short secret, where ends would give away most of it', () => { + expect(previewOf('abcdefgh', true)).toBe('••••••••'); + }); + + it('shows nothing when there is nothing stored', () => { + expect(previewOf('', true)).toBe(''); + }); +}); + +describe('ask', () => { + it('keeps the existing value when the user just presses Enter', async () => { + mockQueued = ['']; + const io = createIo(); + expect(await io.ask('Project URL', { fallback: 'https://old.supabase.co' })).toBe('https://old.supabase.co'); + }); + + it('re-asks until the validator is satisfied', async () => { + mockQueued = ['not-a-url', 'https://abcdefgh.supabase.co']; + const validators = require('../../bot/scripts/setup/validators'); + const io = createIo(); + + expect(await io.ask('Project URL', { validate: validators.supabaseUrl })) + .toBe('https://abcdefgh.supabase.co'); + expect(mockQueued).toHaveLength(0); + }); + + it('stores the validator\'s cleaned value, not the raw paste', async () => { + // Trailing slashes and stray quotes are the user's tooling, not their + // intent — cleaning beats asking someone to paste tidily. + mockQueued = ['https://abcdefgh.supabase.co/']; + const validators = require('../../bot/scripts/setup/validators'); + const io = createIo(); + + expect(await io.ask('Project URL', { validate: validators.supabaseUrl })) + .toBe('https://abcdefgh.supabase.co'); + }); + + it('trims what was typed', async () => { + mockQueued = [' spaced-value ']; + expect(await createIo().ask('Key')).toBe('spaced-value'); + }); +}); + +describe('confirm', () => { + it('takes the default on Enter, in both directions', async () => { + const io = createIo(); + mockQueued = ['']; + expect(await io.confirm('Ready?', true)).toBe(true); + mockQueued = ['']; + expect(await io.confirm('Ready?', false)).toBe(false); + }); + + it('reads yes and no', async () => { + const io = createIo(); + mockQueued = ['y']; + expect(await io.confirm('Ready?', false)).toBe(true); + mockQueued = ['no']; + expect(await io.confirm('Ready?', true)).toBe(false); + }); +}); + +describe('select without a terminal', () => { + const OPTIONS = [ + { label: 'Just trying it out', value: 'baileys', hint: 'nothing to register' }, + { label: 'Real deployment', value: 'meta', hint: 'needs a Meta account' }, + ]; + + it('falls back to a numbered list rather than refusing to ask', async () => { + mockQueued = ['2']; + expect(await readChoice('How are you using Rumi?', OPTIONS, 0)).toBe('meta'); + }); + + it('takes the default on Enter', async () => { + mockQueued = ['']; + expect(await readChoice('How are you using Rumi?', OPTIONS, 0)).toBe('baileys'); + }); + + it('falls back to the default rather than crashing on a nonsense answer', async () => { + mockQueued = ['9']; + expect(await readChoice('How are you using Rumi?', OPTIONS, 0)).toBe('baileys'); + }); + + it('shows each option\'s wording, so the numbered fallback is still explained', async () => { + mockQueued = ['']; + await readChoice('How are you using Rumi?', OPTIONS, 0); + const printed = console.log.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(printed).toContain('Just trying it out'); + expect(printed).toContain('Real deployment'); + }); +}); + +describe('PromptAbortError', () => { + it('is recognisable as a deliberate cancellation, not a crash', () => { + const err = new PromptAbortError(); + expect(err.aborted).toBe(true); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/tests/setup/status.test.js b/tests/setup/status.test.js new file mode 100644 index 0000000..89ae1d1 --- /dev/null +++ b/tests/setup/status.test.js @@ -0,0 +1,198 @@ +/** + * status.js — `rumi status`. + * + * The two facts this command exists to report are the two a credentials + * checklist cannot give you: whether a Rumi process is actually up, and which + * WhatsApp account it answers as. Both are read from files on disk, so both are + * testable without starting anything. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const status = require('../../bot/scripts/setup/status'); +const summary = require('../../bot/scripts/setup/summary'); + +/** A CHANNEL_STATE_DIR laid out the way baileys-connection writes it. */ +function stateDir(contents = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rumi-status-test-')); + const dir = path.join(root, 'baileys'); + fs.mkdirSync(dir, { recursive: true }); + if (contents.lock) fs.writeFileSync(path.join(dir, '.instance.lock'), JSON.stringify(contents.lock)); + if (contents.creds) fs.writeFileSync(path.join(dir, 'creds.json'), contents.creds); + return root; +} + +describe('processState', () => { + it('reports running when the lock is held by a live process', () => { + // The lock exists to stop two processes sharing 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 with it. + const dir = stateDir({ lock: { pid: process.pid, since: '2026-08-06T12:00:00.000Z' } }); + const state = status.processState({ CHANNEL_STATE_DIR: dir }); + + expect(state).toMatchObject({ running: true, pid: process.pid }); + expect(status.renderProcessLine(state)).toMatch(/running/); + }); + + it('calls a lock held by a dead pid stale, and says it is harmless', () => { + // A crashed process leaves its lock behind. Reporting that as "running" + // would send someone hunting for a process that is not there. + // + // The pid comes from a process that has already exited — spawnSync returns + // only once its child is done — which is a genuinely dead pid rather than a + // number guessed to be unused. + const finished = require('child_process').spawnSync(process.execPath, ['-e', '']); + expect(status.pidIsAlive(finished.pid)).toBe(false); + + const dir = stateDir({ lock: { pid: finished.pid, since: '2026-08-06T12:00:00.000Z' } }); + const state = status.processState({ CHANNEL_STATE_DIR: dir }); + + expect(state).toMatchObject({ running: false, stale: true, pid: finished.pid }); + expect(status.renderProcessLine(state)).toMatch(/stale lock, harmless/); + }); + + it('reports not running when there is no lock at all, and says how to start', () => { + const state = status.processState({ CHANNEL_STATE_DIR: stateDir() }); + expect(state.running).toBe(false); + expect(status.renderProcessLine(state)).toMatch(/rumi start/); + }); +}); + +describe('sandboxIdentity', () => { + it('reads which number is linked from the stored session, without connecting', () => { + const dir = stateDir({ creds: JSON.stringify({ me: { id: '923001234567:12@s.whatsapp.net', name: 'Rumi' } }) }); + expect(status.sandboxIdentity({ CHANNEL_STATE_DIR: dir })).toMatchObject({ + paired: true, number: '923001234567', name: 'Rumi', + }); + }); + + it('reports not paired when no session has been stored yet', () => { + expect(status.sandboxIdentity({ CHANNEL_STATE_DIR: stateDir() })).toEqual({ paired: false }); + }); + + it('reports not paired rather than throwing on a corrupt session file', () => { + const dir = stateDir({ creds: 'not json at all' }); + expect(status.sandboxIdentity({ CHANNEL_STATE_DIR: dir })).toEqual({ paired: false }); + }); +}); + +describe('the readiness view', () => { + const doctorResult = (overrides = {}) => ({ + ok: true, + channel: 'baileys', + missingRequired: [], + probeResults: [ + { name: 'Supabase', status: 'pass', detail: 'HTTP 200' }, + { name: 'OpenRouter (LLM)', status: 'pass', detail: 'HTTP 200 · $9.69 credit remaining' }, + { name: 'Redis', status: 'pass', detail: 'PONG' }, + { name: 'WhatsApp Cloud API', status: 'skip', detail: 'not configured' }, + ], + featureResults: [ + { name: 'Voice notes (speech-to-text, Soniox)', status: 'on', detail: 'keys present', requiredKeys: ['SONIOX_API_KEY'], missingKeys: [] }, + { name: 'Lesson-plan generation (Gamma)', status: 'off', detail: 'set: GAMMA_API_KEY', requiredKeys: ['GAMMA_API_KEY'], missingKeys: ['GAMMA_API_KEY'] }, + ], + ...overrides, + }); + + it('names services by what they do, not by the vendor that provides them', () => { + const rendered = summary.renderReadiness(doctorResult(), { number: '923001234567' }); + expect(rendered).toContain('Memory (database)'); + expect(rendered).toContain('Thinking (AI)'); + expect(rendered).not.toContain('Supabase'); + }); + + it('keeps a credit balance, which is worth knowing, and drops "HTTP 200", which is not', () => { + const rendered = summary.renderReadiness(doctorResult(), {}); + expect(rendered).toContain('$9.69 credit remaining'); + expect(rendered).not.toContain('HTTP 200'); + }); + + it('hides a skipped probe instead of showing a service nobody configured', () => { + const rendered = summary.renderReadiness(doctorResult(), { number: '923001234567' }); + expect(rendered).not.toContain('not configured'); + }); + + it('shows which key would switch an off feature on', () => { + expect(summary.renderReadiness(doctorResult(), {})).toContain('GAMMA_API_KEY'); + }); + + it('states plainly when the sandbox channel has not been linked yet', () => { + const rendered = summary.renderReadiness(doctorResult(), { number: null }); + expect(rendered).toMatch(/not linked yet/); + expect(rendered).toMatch(/rumi pair/); + }); + + it('reports a failing service with the reason attached', () => { + const failing = doctorResult({ + ok: false, + probeResults: [{ name: 'Redis', status: 'fail', detail: 'connect ECONNREFUSED 127.0.0.1:6379' }], + }); + const rendered = summary.renderReadiness(failing, {}); + expect(rendered).toMatch(/not working/); + expect(rendered).toContain('ECONNREFUSED'); + }); +}); + +describe('what to do next', () => { + it('leads with the one command that starts Rumi', () => { + expect(summary.renderNextSteps({ channel: 'baileys', number: '923001234567' })) + .toContain('rumi start'); + }); + + it('tells a sandbox user which number to message', () => { + expect(summary.renderNextSteps({ channel: 'baileys', number: '923001234567' })) + .toContain('+923001234567'); + }); + + it('does not offer `rumi pair` on Meta, where there is nothing to pair', () => { + const rendered = summary.renderNextSteps({ channel: 'meta' }); + expect(rendered).not.toContain('rumi pair'); + expect(rendered).toMatch(/webhook/i); + }); + + it('suggests things to try that actually exist as commands', () => { + const rendered = summary.renderNextSteps({ channel: 'baileys', number: '1' }); + expect(rendered).toContain('/menu'); + expect(rendered).toContain('/reading test'); + }); +}); + +describe('a pairing that succeeded is never reported as "not linked"', () => { + // Seen live: a fresh clone paired successfully ("✔ Linked"), and the closing + // screen three lines later said "not linked yet — run `rumi pair`". The link + // was fine; only the *number* was unknown, because of a timing race in how it + // was read. Rendering keyed on the number rather than on the link turned a + // cosmetic gap into a flat contradiction. + const baileysDoctor = { + ok: true, + channel: 'baileys', + missingRequired: [], + probeResults: [{ name: 'Supabase', status: 'pass', detail: 'HTTP 200' }], + featureResults: [], + }; + + it('reports linked, even when the number could not be read', () => { + const rendered = summary.renderReadiness(baileysDoctor, { linked: true, number: null }); + expect(rendered).toMatch(/linked/); + expect(rendered).not.toMatch(/not linked yet/); + }); + + it('names the number when it is known', () => { + expect(summary.renderReadiness(baileysDoctor, { linked: true, number: '923001234567' })) + .toContain('+923001234567'); + }); + + it('still says "not linked yet" when the user declined to pair', () => { + expect(summary.renderReadiness(baileysDoctor, { linked: false, number: null })) + .toMatch(/not linked yet/); + }); + + it('falls back to inferring from the number for callers that pass no link state', () => { + // `rumi status` has no pairing outcome to report — only what is on disk. + expect(summary.renderReadiness(baileysDoctor, { number: '923001234567' })).toMatch(/linked/); + expect(summary.renderReadiness(baileysDoctor, {})).toMatch(/not linked yet/); + }); +}); diff --git a/tests/setup/ui.test.js b/tests/setup/ui.test.js new file mode 100644 index 0000000..9ae1a26 --- /dev/null +++ b/tests/setup/ui.test.js @@ -0,0 +1,176 @@ +/** + * ui.js — the CLI's presentation layer. + * + * Two things here are load-bearing rather than cosmetic. Colour must switch + * itself off when nothing human is reading, or every captured log and every + * assertion in this repo fills with escape codes. And box widths must be + * computed from *printed* width, not string length, or any line containing + * colour or an emoji pushes the border out and the whole frame goes ragged. + */ + +const ui = require('../../bot/scripts/setup/ui'); + +afterEach(() => { + ui.setColorEnabled(null); + delete process.env.NO_COLOR; +}); + +describe('colour', () => { + it('is off when output is not a terminal — logs and tests stay plain', () => { + // Jest captures stdout, so isTTY is false here: exactly the case this + // guarantee exists for. + expect(ui.ok('done')).toBe('✔ done'); + expect(ui.paint('brand', 'hi')).toBe('hi'); + }); + + it('is off when NO_COLOR is set, even on a terminal', () => { + process.env.NO_COLOR = '1'; + expect(ui.colorEnabled()).toBe(false); + }); + + it('wraps text in escape codes once enabled', () => { + ui.setColorEnabled(true); + const painted = ui.paint('brand', 'hi'); + expect(painted).toMatch(/^\[/); + expect(painted).toMatch(/\[0m$/); + expect(ui.stripAnsi(painted)).toBe('hi'); + }); +}); + +describe('visibleWidth', () => { + it('ignores escape codes', () => { + ui.setColorEnabled(true); + expect(ui.visibleWidth(ui.paint('brand', 'hello'))).toBe(5); + }); + + it('counts emoji as the two cells they occupy', () => { + expect(ui.visibleWidth('✅')).toBe(2); + expect(ui.visibleWidth('📱ok')).toBe(4); + }); + + it('counts a variation selector as nothing', () => { + // "⚠️" is a base character plus U+FE0F; treating the selector as a cell + // would over-pad every line containing one. + expect(ui.visibleWidth('⚠️')).toBe(1); + }); + + it('counts the box-drawing and block characters the logo uses as single cells', () => { + expect(ui.visibleWidth('╭─╮')).toBe(3); + expect(ui.visibleWidth('██████╗')).toBe(7); + }); +}); + +describe('wrap', () => { + it('never exceeds the requested width', () => { + const text = 'Rumi remembers every teacher, lesson plan and reading score in a database that belongs to you.'; + for (const line of ui.wrap(text, 40)) expect(ui.visibleWidth(line)).toBeLessThanOrEqual(40); + }); + + it('leaves a long URL intact rather than splitting it', () => { + // A split URL cannot be clicked or copied, which defeats the point of + // printing it. + const url = 'https://supabase.com/dashboard/project/abcdefghijklmnop/sql/new'; + expect(ui.wrap(`Open ${url} now`, 30)).toContain(url); + }); + + it('keeps explicit line breaks', () => { + expect(ui.wrap('one\ntwo', 40)).toEqual(['one', 'two']); + }); +}); + +describe('box', () => { + it('draws every line to the same printed width, colour or not', () => { + ui.setColorEnabled(true); + const lines = ui.box(['short', ui.paint('accent', 'a coloured line that is longer')], { title: 'copy this' }) + .split('\n'); + const widths = new Set(lines.map((l) => ui.visibleWidth(l))); + expect(widths.size).toBe(1); + }); + + it('grows to fit its title, so a long title never overruns the frame', () => { + const lines = ui.box(['x'], { title: 'a title longer than the content' }).split('\n'); + const widths = new Set(lines.map((l) => ui.visibleWidth(l))); + expect(widths.size).toBe(1); + }); + + it('fits inside the terminal for content that fits', () => { + const lines = ui.box(['create or replace function exec_sql(query text)']).split('\n'); + for (const line of lines) expect(ui.visibleWidth(line)).toBeLessThanOrEqual(ui.measure()); + }); + + it('keeps the frame consistent even around a line too wide for the window', () => { + // Content is never clipped — a box here holds SQL meant to be copied, and a + // truncated line that looks complete is worse than one the terminal wraps. + const lines = ui.box(['y'.repeat(200)]).split('\n'); + expect(new Set(lines.map((l) => ui.visibleWidth(l))).size).toBe(1); + }); +}); + +describe('logo', () => { + it('prints the wordmark at a normal width', () => { + expect(ui.logo()).toContain('██████╗'); + }); + + it('degrades to plain text on a terminal too narrow for block letters', () => { + const original = process.stdout.columns; + Object.defineProperty(process.stdout, 'columns', { value: 24, configurable: true }); + try { + const rendered = ui.logo(); + expect(rendered).not.toContain('██'); + expect(rendered).toContain('RUMI'); + } finally { + Object.defineProperty(process.stdout, 'columns', { value: original, configurable: true }); + } + }); +}); + +describe('step', () => { + it('says where you are, and fills the bar as you go', () => { + expect(ui.step(1, 5, 'Where Rumi keeps its memory')).toContain('step 1 of 5'); + const first = ui.step(1, 5, 'a'); + const last = ui.step(5, 5, 'b'); + const filled = (text) => (text.match(/━/g) || []).length; + expect(filled(last)).toBeGreaterThan(filled(first)); + }); +}); + +describe('steps', () => { + it('colours a URL but not the punctuation after it', () => { + ui.setColorEnabled(true); + const rendered = ui.steps(['Sign up at https://upstash.com, then copy the URL']); + // The comma ends the sentence, not the address — colouring it in suggests + // otherwise to anyone about to retype the link. + expect(rendered).toContain(`${ui.link('https://upstash.com')},`); + }); + + it('numbers each item and hangs its wrapped continuation under the text', () => { + ui.setColorEnabled(false); + const long = 'Give the project any name, choose the region closest to your teachers, and let it start up.'; + const lines = ui.steps([long]).split('\n'); + + expect(lines).toHaveLength(2); + expect(lines[0]).toMatch(/^ {2}1\. /); + // Indented past the "1. " so the number stays the only thing in that column. + expect(lines[1]).toMatch(/^ {5}\S/); + }); +}); + +describe('table', () => { + it('aligns values into one column regardless of label length', () => { + const rendered = ui.table([['a', 'x'], ['a much longer label', 'y']]).split('\n'); + expect(rendered[0].indexOf('x')).toBe(rendered[1].indexOf('y')); + }); +}); + +describe('spinner', () => { + it('prints exactly one result line and stops cleanly without a terminal', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const spin = ui.spinner('Checking…'); + spin.succeed('Connected'); + const printed = logSpy.mock.calls.map((c) => c.join(' ')); + logSpy.mockRestore(); + + expect(printed).toContain('✔ Connected'); + expect(printed.filter((l) => l.includes('Connected'))).toHaveLength(1); + }); +}); diff --git a/tests/setup/validators.test.js b/tests/setup/validators.test.js new file mode 100644 index 0000000..452e565 --- /dev/null +++ b/tests/setup/validators.test.js @@ -0,0 +1,177 @@ +/** + * validators.js — the paste-mistake catches. + * + * Every case below is a value that is *well-formed for something else*, which + * is why none of them is caught by a presence check and all of them cost real + * debugging time. The assertions care as much about the explanation as the + * verdict: "invalid key" sends someone back to the same wrong tab, while + * "that's the anon key, click Reveal for the other one" ends the problem. + */ + +const v = require('../../bot/scripts/setup/validators'); + +/** Builds a Supabase-shaped JWT carrying the given role claim. */ +function jwtWithRole(role) { + const payload = Buffer.from(JSON.stringify({ iss: 'supabase', role })).toString('base64url'); + return `eyJhbGciOiJIUzI1NiJ9.${payload}.c2lnbmF0dXJl`; +} + +describe('Supabase service key', () => { + it('accepts the service_role key', () => { + expect(v.supabaseServiceKey(jwtWithRole('service_role')).ok).toBe(true); + }); + + it('rejects the anon key, and explains what to click instead', () => { + // The expensive one. Both keys are JWTs starting "eyJ" and sit on the same + // page; the anon key cannot see past row-level security, so the bot starts + // cleanly and then behaves as though the database were empty. + const verdict = v.supabaseServiceKey(jwtWithRole('anon')); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(/anon/i); + expect(verdict.reason).toMatch(/service_role/); + expect(verdict.reason).toMatch(/reveal/i); + }); + + it('rejects the publishable key of the newer key format', () => { + const verdict = v.supabaseServiceKey('sb_publishable_abc123'); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(/sb_secret_/); + }); + + it('accepts the newer secret key format', () => { + expect(v.supabaseServiceKey('sb_secret_abc123').ok).toBe(true); + }); + + it('notices the project URL pasted into the key field', () => { + expect(v.supabaseServiceKey('https://abc.supabase.co').reason).toMatch(/project URL/i); + }); + + it('lets an undecodable JWT through for the live check to judge', () => { + // Guessing at shape must never block a key that might be right — the live + // probe two lines later is the real authority. + expect(v.supabaseServiceKey('eyJsomethingunexpected').ok).toBe(true); + }); + + it('strips wrapping quotes rather than making the user paste tidily', () => { + expect(v.supabaseServiceKey(`"${jwtWithRole('service_role')}"`).value).not.toMatch(/"/); + }); +}); + +describe('Supabase project URL', () => { + it('accepts a project URL and drops a trailing slash', () => { + expect(v.supabaseUrl('https://abcdefgh.supabase.co/')).toEqual({ ok: true, value: 'https://abcdefgh.supabase.co' }); + }); + + it('adds the scheme when only the host was pasted', () => { + expect(v.supabaseUrl('abcdefgh.supabase.co').value).toBe('https://abcdefgh.supabase.co'); + }); + + it('catches the dashboard page being pasted instead of the API URL', () => { + const verdict = v.supabaseUrl('https://supabase.com/dashboard/project/abcdefgh'); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(/dashboard page/i); + }); + + it('catches a key pasted into the URL field', () => { + expect(v.supabaseUrl(jwtWithRole('service_role')).reason).toMatch(/looks like a key/i); + }); + + it('allows a local/self-hosted address', () => { + expect(v.supabaseUrl('http://localhost:54321').ok).toBe(true); + }); +}); + +describe('OpenRouter key', () => { + it('accepts an OpenRouter key', () => { + expect(v.openrouterKey('sk-or-v1-0123456789abcdef').ok).toBe(true); + }); + + it.each([ + ['sk-ant-api03-abc', /Anthropic/], + ['sk-proj-abc123', /OpenAI/], + ['AIzaSyAbc123', /Google/], + ['xoxb-123-abc', /Slack/], + [`EAA${'x'.repeat(120)}`, /Meta|WhatsApp/], + ])('names the vendor when %s is pasted by mistake', (key, expected) => { + // Every AI provider hands out an "sk-…" and they are indistinguishable in a + // terminal, so saying which one this is saves the round trip. + const verdict = v.openrouterKey(key); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(expected); + }); + + it('spots a truncated paste', () => { + expect(v.openrouterKey('sk-or-v1-abc').reason).toMatch(/truncated/i); + }); +}); + +describe('Redis address', () => { + it('accepts redis:// and rediss://', () => { + expect(v.redisUrl('redis://localhost:6379').ok).toBe(true); + expect(v.redisUrl('rediss://default:pw@host.upstash.io:6379').ok).toBe(true); + }); + + it('wraps a bare host:port, which is what most dashboards show', () => { + expect(v.redisUrl('my-redis.internal:6379').value).toBe('redis://my-redis.internal:6379'); + }); + + it('explains the Upstash trap of copying the https endpoint', () => { + const verdict = v.redisUrl('https://eager-cat-12345.upstash.io'); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(/redis:\/\//); + }); +}); + +describe('Meta credentials', () => { + it('accepts a full-length access token', () => { + expect(v.whatsappToken(`EAA${'x'.repeat(200)}`).ok).toBe(true); + }); + + it('rejects a token that does not start with EAA, naming what was pasted', () => { + expect(v.whatsappToken('sk-proj-abc').reason).toMatch(/OpenAI/); + expect(v.whatsappToken('random-string').reason).toMatch(/EAA/); + }); + + it('rejects a truncated token, with the length it saw', () => { + expect(v.whatsappToken('EAAshort').reason).toMatch(/8 characters/); + }); + + it('catches a phone number in the phone number ID field', () => { + // Meta's #1 setup trap: the field wants their internal 15-17 digit id, and + // Graph's answer to a phone number is "Object with ID does not exist", + // which names neither the field nor the mistake. + const verdict = v.phoneNumberId('15556422442'); + expect(verdict.ok).toBe(false); + expect(verdict.reason).toMatch(/looks like the phone number/i); + expect(verdict.reason).toMatch(/From/); + }); + + it('accepts a real phone number ID and tolerates spacing', () => { + expect(v.phoneNumberId(' 779418925277868 ').ok).toBe(true); + }); + + it('rejects a phone number ID with a plus or letters', () => { + expect(v.phoneNumberId('+923001234567').reason).toMatch(/digits only/i); + }); + + it('requires the webhook password to be usable in a URL', () => { + expect(v.webhookVerifyToken('has spaces here').reason).toMatch(/no spaces/i); + expect(v.webhookVerifyToken('short').reason).toMatch(/8 characters/); + expect(v.webhookVerifyToken('a-good-long-password').ok).toBe(true); + }); +}); + +describe('validatorFor', () => { + it('gives every channel-required var a real shape check, not just a presence check', () => { + const { CHANNEL_REQUIRED_VARS } = require('../../bot/shared/config/feature-availability'); + for (const key of CHANNEL_REQUIRED_VARS.meta) { + expect(v.BY_ENV_VAR[key]).toBeDefined(); + } + }); + + it('falls back to a presence check for a var it has no opinion about', () => { + const check = v.validatorFor('SOME_FUTURE_KEY'); + expect(check('').ok).toBe(false); + expect(check('anything').ok).toBe(true); + }); +}); diff --git a/tests/unit/no-undeliverable-promises.test.js b/tests/unit/no-undeliverable-promises.test.js new file mode 100644 index 0000000..cb7692a --- /dev/null +++ b/tests/unit/no-undeliverable-promises.test.js @@ -0,0 +1,76 @@ +/** + * The bot must not offer something this deployment cannot deliver. + * + * Both cases below were seen live, and both come from the same shape: an asset + * URL built by interpolating a base that isn't configured, producing a RELATIVE + * path that nothing can fetch — while the surrounding code treats a non-empty + * string as "available". + * + * - feature intro videos: the bot asked "Want to see how? 🎥", the teacher + * accepted, and nothing arrived ("Could not extract R2 key from URL: + * /feature_videos/reading_intro.mp4"). + * - reading passage backgrounds: a decorative image took the whole passage down + * with "TypeError: Invalid URL". + */ + +const R2_PUBLIC_URL = 'https://pub-example.r2.dev'; + +function loadFeatureVideos(baseUrl) { + jest.resetModules(); + if (baseUrl) process.env.R2_PUBLIC_URL = baseUrl; + else delete process.env.R2_PUBLIC_URL; + return require('../../bot/shared/constants/feature-videos'); +} + +afterEach(() => { + delete process.env.R2_PUBLIC_URL; + jest.resetModules(); +}); + +describe('feature intro videos are presence-gated', () => { + it('are null when no public base URL is configured', () => { + const { FEATURE_VIDEO_URLS } = loadFeatureVideos(null); + expect(FEATURE_VIDEO_URLS.lesson_plan).toBeNull(); + expect(FEATURE_VIDEO_URLS.coaching).toBeNull(); + expect(FEATURE_VIDEO_URLS.reading).toBeNull(); + }); + + it('never produce a relative path — the bug that made the offer undeliverable', () => { + const { FEATURE_VIDEO_URLS } = loadFeatureVideos(null); + for (const url of Object.values(FEATURE_VIDEO_URLS)) { + if (url !== null) expect(url).toMatch(/^https?:\/\//); + } + }); + + it('are absolute URLs once a base is configured', () => { + const { FEATURE_VIDEO_URLS } = loadFeatureVideos(R2_PUBLIC_URL); + expect(FEATURE_VIDEO_URLS.reading).toBe(`${R2_PUBLIC_URL}/feature_videos/reading_intro.mp4`); + }); + + it('the consent offer is gated on actually having a video', () => { + // suggestNext() sends the plain text suggestion instead of a "watch this" + // button when there is no video — asserted against the source, since the + // function needs Redis, the DB and a live socket to run. + const src = require('fs').readFileSync( + require('path').resolve(__dirname, '../../bot/shared/services/feature-linker.service.js'), 'utf-8' + ); + expect(src).toMatch(/hasVideoToShow = Boolean\(FEATURE_VIDEO_URLS\[link\.feature\]\)/); + expect(src).toMatch(/if \(hasSeenVideo \|\| !hasVideoToShow\)/); + }); +}); + +describe('reading passage backgrounds are presence-gated', () => { + it('getRandomBackgroundUrl returns null with no base URL, rather than a relative path', () => { + jest.resetModules(); + delete process.env.R2_PUBLIC_URL; + jest.doMock('../../bot/shared/config/supabase', () => ({ from: jest.fn() }), { virtual: true }); + jest.doMock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + + const PassageGeneration = require('../../bot/shared/services/reading/passage-generation.service'); + // Every passage type must be safe, not just the one that happens to be first. + for (const type of ['letters', 'words', 'sentences', 'paragraph', 'story']) { + const url = PassageGeneration.getRandomBackgroundUrl(type); + if (url !== null) expect(url).toMatch(/^https?:\/\//); + } + }); +}); diff --git a/tests/unit/sprint-0/security-scan.test.js b/tests/unit/sprint-0/security-scan.test.js index cd6b8a2..da8d1fb 100644 --- a/tests/unit/sprint-0/security-scan.test.js +++ b/tests/unit/sprint-0/security-scan.test.js @@ -171,6 +171,11 @@ describe('Security Scan', () => { if (file.includes('migrate.js')) continue; // Allow the DB bootstrapper which applies the canonical schema via exec_sql RPC if (file.includes('bootstrap-db.js')) continue; + // Allow `rumi setup`'s database step, which probes for the same + // helper (and prints its definition) so it can tell "no tables yet" + // apart from "no way to create them" — the operator-run setup path, + // same category as bootstrap-db.js, not a runtime SQL surface. + if (file.includes('scripts/setup/db-setup.js')) continue; throw new Error(`Active exec_sql reference found at ${file}:${i + 1}: ${line.trim()}`); } } diff --git a/tests/unit/sprint-1/feature-availability.test.js b/tests/unit/sprint-1/feature-availability.test.js index 622f45c..d7721ad 100644 --- a/tests/unit/sprint-1/feature-availability.test.js +++ b/tests/unit/sprint-1/feature-availability.test.js @@ -2,6 +2,10 @@ * Presence-based feature availability (replaces the removed tier system). * A feature is available iff its required env key(s) are set; CHANGEME * placeholders count as not-set; missing required vars block boot. + * + * The messaging channel (meta | baileys) is gated the same presence-based + * way, scoped by CHANNEL_DRIVER — see the "channel driver resolution" block + * below. */ const fa = require('../../../bot/shared/config/feature-availability'); @@ -17,14 +21,21 @@ const FULL_ENV = { WABA_ID: 'k', }; +const CORE_ENV = { + SUPABASE_URL: 'https://x.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'k', + OPENROUTER_API_KEY: 'k', + REDIS_URL: 'redis://localhost:6379', +}; + describe('feature-availability (presence-based gating)', () => { - it('exposes the 8 required vars and a feature list', () => { - expect(fa.REQUIRED_VARS).toHaveLength(8); + it('exposes the 4 core required vars (channel vars are gated separately) and a feature list', () => { + expect(fa.REQUIRED_VARS).toHaveLength(4); expect(Array.isArray(fa.FEATURES)).toBe(true); expect(fa.FEATURES.length).toBeGreaterThan(0); }); - it('missingRequired is empty when all required vars are set', () => { + it('missingRequired is empty when all required vars are set (Meta vars present → infers meta)', () => { expect(fa.missingRequired(FULL_ENV)).toEqual([]); }); @@ -33,6 +44,55 @@ describe('feature-availability (presence-based gating)', () => { expect(fa.missingRequired({ ...FULL_ENV, OPENROUTER_API_KEY: 'CHANGEME-x' })).toContain('OPENROUTER_API_KEY'); }); + describe('channel driver resolution', () => { + it('CHANNEL_DRIVER=meta requires the 4 WhatsApp vars', () => { + const env = { ...CORE_ENV, CHANNEL_DRIVER: 'meta' }; + expect(fa.resolveChannelDriver(env)).toBe('meta'); + expect(fa.missingRequired(env)).toEqual( + expect.arrayContaining(['WHATSAPP_TOKEN', 'PHONE_NUMBER_ID', 'WEBHOOK_VERIFY_TOKEN', 'WABA_ID']) + ); + }); + + it('CHANNEL_DRIVER=baileys requires none of the WhatsApp vars', () => { + const env = { ...CORE_ENV, CHANNEL_DRIVER: 'baileys' }; + expect(fa.resolveChannelDriver(env)).toBe('baileys'); + expect(fa.missingRequired(env)).toEqual([]); + }); + + it('an unknown CHANNEL_DRIVER value falls back to the sandbox default (baileys)', () => { + const env = { ...CORE_ENV, CHANNEL_DRIVER: 'telegram' }; + expect(fa.resolveChannelDriver(env)).toBe('baileys'); + expect(fa.missingRequired(env)).toEqual([]); + }); + + it('with no CHANNEL_DRIVER set, infers meta when ANY Meta var is already present (backward compat)', () => { + // A pre-existing or partially-configured Meta deployment must keep + // being told what's missing, not get silently reclassified as sandbox. + const env = { ...CORE_ENV, WHATSAPP_TOKEN: 'k' }; + expect(fa.resolveChannelDriver(env)).toBe('meta'); + expect(fa.missingRequired(env)).toEqual( + expect.arrayContaining(['PHONE_NUMBER_ID', 'WEBHOOK_VERIFY_TOKEN', 'WABA_ID']) + ); + }); + + it('with no CHANNEL_DRIVER set and no Meta vars present, defaults to sandbox (baileys)', () => { + expect(fa.resolveChannelDriver(CORE_ENV)).toBe('baileys'); + expect(fa.missingRequired(CORE_ENV)).toEqual([]); + }); + + it('CHANNEL_REQUIRED_VARS maps meta to the 4 WhatsApp vars and baileys to none', () => { + expect(fa.CHANNEL_REQUIRED_VARS.meta).toEqual( + ['WHATSAPP_TOKEN', 'PHONE_NUMBER_ID', 'WEBHOOK_VERIFY_TOKEN', 'WABA_ID'] + ); + expect(fa.CHANNEL_REQUIRED_VARS.baileys).toEqual([]); + }); + + it('requiredVarsFor combines the core vars with whichever channel is resolved', () => { + expect(fa.requiredVarsFor({ ...CORE_ENV, CHANNEL_DRIVER: 'meta' })).toHaveLength(8); + expect(fa.requiredVarsFor({ ...CORE_ENV, CHANNEL_DRIVER: 'baileys' })).toHaveLength(4); + }); + }); + it('a feature is available only when ALL its keys are present', () => { const azure = fa.FEATURES.find((f) => f.name.includes('Azure')); expect(fa.isFeatureAvailable(azure, { ...FULL_ENV, AZURE_SPEECH_KEY: 'k' })).toBe(false); // region missing diff --git a/tests/unit/sprint-3/setup-infrastructure.test.js b/tests/unit/sprint-3/setup-infrastructure.test.js index a51f1ad..254cf6d 100644 --- a/tests/unit/sprint-3/setup-infrastructure.test.js +++ b/tests/unit/sprint-3/setup-infrastructure.test.js @@ -26,20 +26,20 @@ describe('Setup Infrastructure', () => { }); test('validateEnv returns valid:false when required vars missing', () => { - const originalTier = process.env.RUMI_TIER; - const originalKey = process.env.OPENROUTER_API_KEY; - process.env.RUMI_TIER = 'minimal'; - delete process.env.WHATSAPP_TOKEN; - delete process.env.OPENROUTER_API_KEY; - - jest.resetModules(); + // An explicit, self-contained env (not process.env mutation) — avoids + // depending on dotenv's reload-on-require behavior or on the real local + // .env file's contents, both of which are incidental to what this test + // actually checks: a missing CORE required var (channel-independent). const { validateEnv } = require(path.join(ROOT, 'bot/scripts/validate-env.js')); - const result = validateEnv(); + const env = { + SUPABASE_URL: 'https://x.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'k', + REDIS_URL: 'redis://localhost:6379', + // OPENROUTER_API_KEY intentionally omitted. + }; + const result = validateEnv(env); expect(result.valid).toBe(false); - expect(result.missing.length).toBeGreaterThan(0); - - process.env.RUMI_TIER = originalTier; - process.env.OPENROUTER_API_KEY = originalKey; + expect(result.missing).toContain('OPENROUTER_API_KEY'); }); test('validateEnv reports presence-based features (no tier)', () => { diff --git a/tests/unit/sprint-4/docs-completeness.test.js b/tests/unit/sprint-4/docs-completeness.test.js index 0b1795c..658df17 100644 --- a/tests/unit/sprint-4/docs-completeness.test.js +++ b/tests/unit/sprint-4/docs-completeness.test.js @@ -22,7 +22,9 @@ describe('Documentation Completeness', () => { }); test('contains project description', () => { - expect(readme).toContain('AI Teaching Assistant'); + // Case-insensitive: the guard is that the README says what Rumi *is*, + // not how the phrase is capitalised in a given tagline. + expect(readme).toMatch(/AI teaching assistant/i); expect(readme).toContain('WhatsApp'); }); diff --git a/tests/unit/student-list-parsing.test.js b/tests/unit/student-list-parsing.test.js new file mode 100644 index 0000000..853db1b --- /dev/null +++ b/tests/unit/student-list-parsing.test.js @@ -0,0 +1,128 @@ +/** + * StudentListService.parseStudentText — the roster parser. + * + * Load-bearing for class setup on any channel without a Flow, where the teacher + * types the whole roster as one message (see messaging/text-flow-definitions.js). + * The optional parent phone number matters: quizzes and reports are delivered to + * parents, so a roster with no numbers gets as far as the class picker and stops. + */ + +jest.mock('../../bot/shared/config/supabase', () => ({ from: jest.fn() }), { virtual: true }); +jest.mock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() })); + +const StudentListService = require('../../bot/shared/services/student-list.service'); + +const parse = (text) => StudentListService.parseStudentText(text); + +describe('names', () => { + it('takes one student per line', () => { + expect(parse('Ahmed Khan\nBilal Hussain')).toEqual([ + { studentName: 'Ahmed Khan', fatherName: null, parentPhone: null }, + { studentName: 'Bilal Hussain', fatherName: null, parentPhone: null }, + ]); + }); + + it('splits s/o and d/o into student and father', () => { + expect(parse('Zara d/o Abdul Ghaffar')).toEqual([ + { studentName: 'Zara', fatherName: 'Abdul Ghaffar', parentPhone: null }, + ]); + }); + + it('treats a comma as "Name, Father Name"', () => { + expect(parse('Ahmed Khan, Khan Sahib')).toEqual([ + { studentName: 'Ahmed Khan', fatherName: 'Khan Sahib', parentPhone: null }, + ]); + }); + + it('strips list numbering and bullets, and ignores blank lines', () => { + expect(parse('1. Ahmed\n\n- Bilal\n• Zara\n \n')).toEqual([ + { studentName: 'Ahmed', fatherName: null, parentPhone: null }, + { studentName: 'Bilal', fatherName: null, parentPhone: null }, + { studentName: 'Zara', fatherName: null, parentPhone: null }, + ]); + }); + + it('handles CRLF line endings', () => { + expect(parse('Ahmed\r\nBilal')).toHaveLength(2); + }); + + it('returns nothing for empty or non-string input', () => { + expect(parse('')).toEqual([]); + expect(parse(null)).toEqual([]); + expect(parse(undefined)).toEqual([]); + expect(parse(' \n \n')).toEqual([]); + }); +}); + +describe('optional parent phone number', () => { + it('extracts an international number and keeps the name clean', () => { + expect(parse('Ahmed Khan +923001234567')).toEqual([ + { studentName: 'Ahmed Khan', fatherName: null, parentPhone: '+923001234567' }, + ]); + }); + + it('extracts a local-format number', () => { + expect(parse('Bilal Hussain 03001234567')).toEqual([ + { studentName: 'Bilal Hussain', fatherName: null, parentPhone: '03001234567' }, + ]); + }); + + it('normalises spaces and dashes out of the number', () => { + expect(parse('Ahmed Khan +92 300 123-4567')[0].parentPhone).toBe('+923001234567'); + }); + + it('keeps s/o parsing working alongside a number', () => { + expect(parse('Zara s/o Abdul 03007654321')).toEqual([ + { studentName: 'Zara', fatherName: 'Abdul', parentPhone: '03007654321' }, + ]); + }); + + it('does not leave a dangling separator when the number followed a comma', () => { + expect(parse('Ahmed Khan, +923001234567')).toEqual([ + { studentName: 'Ahmed Khan', fatherName: null, parentPhone: '+923001234567' }, + ]); + }); + + it('mixes students with and without numbers in one roster', () => { + const rows = parse('Ahmed Khan +923001234567\nBilal Hussain\nZara s/o Abdul 03007654321'); + expect(rows.map((r) => r.parentPhone)).toEqual(['+923001234567', null, '03007654321']); + expect(rows.map((r) => r.studentName)).toEqual(['Ahmed Khan', 'Bilal Hussain', 'Zara']); + }); + + it('does NOT mistake a short number for a phone', () => { + // A grade, a roll number, or a year is not a phone number — the pattern + // deliberately requires 10+ digits. + expect(parse('Ahmed Khan 4')).toEqual([ + { studentName: 'Ahmed Khan 4', fatherName: null, parentPhone: null }, + ]); + expect(parse('Class 2026 Ahmed')[0].parentPhone).toBeNull(); + }); + + it('skips a line that is only a phone number — it names no student', () => { + expect(parse('+923001234567')).toEqual([]); + }); +}); + +describe('createStudentData carries the phone through to the row', () => { + it('maps parentPhone to the parent_phone column', () => { + const row = StudentListService.createStudentData('list-1', { + studentName: 'Ahmed', fatherName: 'Khan', parentPhone: '+923001234567', rollNumber: 1, + }); + expect(row).toEqual({ + list_id: 'list-1', + student_name: 'Ahmed', + father_name: 'Khan', + parent_phone: '+923001234567', + roll_number: 1, + is_active: true, + }); + }); + + it('writes null when no number was given, rather than undefined', () => { + const row = StudentListService.createStudentData('list-1', { + studentName: 'Ahmed', rollNumber: 2, + }); + expect(row.parent_phone).toBeNull(); + expect(row.father_name).toBeNull(); + }); +}); diff --git a/tests/whatsapp/send-image-from-url.test.js b/tests/whatsapp/send-image-from-url.test.js index 9c299a8..e131998 100644 --- a/tests/whatsapp/send-image-from-url.test.js +++ b/tests/whatsapp/send-image-from-url.test.js @@ -9,6 +9,11 @@ * never the raw URL; clean up; and degrade to false (not throw) on failure. */ +// Exercises the real Meta driver directly, so opt into it explicitly rather +// than relying on messaging/index.js's backward-compat inference (which reads +// real WHATSAPP_TOKEN/etc. from process.env, not the mocked constants module). +process.env.CHANNEL_DRIVER = 'meta'; + jest.mock('../../bot/shared/utils/constants', () => ({ WHATSAPP_TOKEN: 'test-token', PHONE_NUMBER_ID: 'test-phone-id', @@ -38,6 +43,8 @@ const R2_URL = 'https://acct.r2.cloudflarestorage.com/bucket/coaching-card-abc.p describe('WhatsAppService.sendImageFromUrl', () => { let sendImageSpy; + afterAll(() => { delete process.env.CHANNEL_DRIVER; }); + beforeEach(() => { jest.clearAllMocks(); extractKeyFromUrl.mockReturnValue('bucket/coaching-card-abc.png'); diff --git a/tests/whatsapp/send-template.test.js b/tests/whatsapp/send-template.test.js index 26b8480..02a5944 100644 --- a/tests/whatsapp/send-template.test.js +++ b/tests/whatsapp/send-template.test.js @@ -11,6 +11,12 @@ // axios + form-data resolve to tests/__mocks__ stubs via jest.config // moduleNameMapper (they live in bot/node_modules, absent during the root job). +// +// Exercises the real Meta driver directly, so opt into it explicitly rather +// than relying on messaging/index.js's backward-compat inference (which reads +// real WHATSAPP_TOKEN/etc. from process.env, not the mocked constants module). +process.env.CHANNEL_DRIVER = 'meta'; + jest.mock('../../bot/shared/utils/constants', () => ({ WHATSAPP_TOKEN: 'test-token', PHONE_NUMBER_ID: 'test-phone-id', @@ -25,6 +31,8 @@ const axios = require('axios'); // the mapped stub — axios.post is a jest.fn const WhatsAppService = require('../../bot/shared/services/whatsapp.service'); describe('WhatsAppService.sendTemplate', () => { + afterAll(() => { delete process.env.CHANNEL_DRIVER; }); + beforeEach(() => { axios.post.mockReset(); axios.post.mockResolvedValue({ data: {}, status: 200 });